Wikilivres
frwikibooks
https://fr.wikibooks.org/wiki/Accueil
MediaWiki 1.47.0-wmf.23
first-letter
Média
Spécial
Discussion
Utilisateur
Discussion utilisateur
Wikilivres
Discussion Wikilivres
Fichier
Discussion fichier
MediaWiki
Discussion MediaWiki
Modèle
Discussion modèle
Aide
Discussion aide
Catégorie
Discussion catégorie
Transwiki
Discussion Transwiki
Wikijunior
Discussion Wikijunior
TimedText
TimedText talk
Module
Discussion module
Event
Event talk
Boîte à jeux
0
74
774866
770639
2026-10-11T17:24:04Z
Cso75
124351
/* Jeux de cartes */ classement de la liste en ordre alphabétique
774866
wikitext
text/x-wiki
{{Page de garde|image=Jeux.png|imagedesc=Jeux|description=
Il existe une multitude de jeux dans le domaine public et il pourrait être intéressant que le projet Wikimedia collecte les règles de ces jeux.
Ce Wikilivre '''boite à jeux''' s'inscrit dans cet objectif, en se limitant volontairement à un type de jeux.
Il s'agit de jeux de société pouvant se jouer autour d'une table avec un matériel relativement restreint :
<div style="border:1px solid #aaaaaa; padding:0.5em; margin:0.5em;">
* papier, ciseaux, règle et crayons,
* 50 pions blanc, 50 pions noirs, 20 pions rouges, 20 pions bleus et 20 pions verts,
* 2 jeux de 54 cartes avec un dos différent,
* 6 dés à six faces,
* 2 gobelets.
</div>
À ce matériel s'ajoutent les plateaux à imprimer qui font partie de ce Wikilivre.
''Ce wikilivre se trouve sur l'étagère [[Wikilivres:Étagère jeu|Jeu]].''
|avancement=Avancé
|cdu=
* {{CDU item|7|79}}
|versions=
{{Moteur}}
{{Version imprimable}}
}}
__NOTOC__
== Règles disponibles ==
<div class="flex-content">
<div class="flex-content-half">
=== Jeux de cartes ===
* [[Boîte à jeux/Vocabulaire|Vocabulaire des jeux de cartes]] : Cette page définit les mots couramment utilisés dans la plupart des jeux de cartes (couleur, atout, couper, se défausser...)
* [[Boîte à jeux/Jeu de la carte|Règles communes du jeu de la carte]] : Cette page explique le déroulement d'un tour de jeu (une levée) dans la plupart des jeux de carte.
{|
| [[Image:Crystal Clear app Cardgame.png|100px]]
|
* [[Boîte à jeux/L'ascenseur|L'Ascenseur]] : à partir de 3 joueurs
* [[Boîte à jeux/Le barbu|Le Barbu]] : 4 joueurs
* [[Boîte à jeux/La bataille corse|La Bataille Corse]] : à partir de 2 joueurs
* [[Boîte à jeux/Bataille_norvégienne|La Bataille Norvégienne]] : à partir de 2 joueurs
* [[Boîte à jeux/La belote|La Belote]] : 4 joueurs
* [[Boîte à jeux/Bonjour monsieur, bonjour madame|Bonjour monsieur, bonjour madame]]: 2 à 4 joueurs
* [[Boîte à jeux/Bouteille ou Yaniv|Bouteille ou Yaniv]] : à partir de 2 joueurs
* [[Boîte à jeux/Chkobba|Chkobba]] : 2, 4 ou 6 joueurs
<!--* [[Boîte à jeux/La crapette|La crapette]] : à partir de 2 joueurs-->
* [[Boîte à jeux/Cribbage|Cribbage]] : 2 à 4 joueurs
* [[Boîte à jeux/L'écarté|L'écarté]] : 2 joueurs
* [[Boîte à jeux/Le Flip|Le Flip]] : 2 joueurs
* [[Boîte à jeux/Gin rami|Gin rami]] : 2 joueurs
* [[Boîte à jeux/Le huit américain|Le Huit Américain]] : à partir de 3 joueurs
* [[Kems]] : 4 joueurs
* [[Boîte à jeux/Kilo de merde|Kilo de merde]] : à partir de 3 joueurs
* [[Boîte à jeux/La manille|La Manille]] : 4 joueurs
* [[Boîte à jeux/Poker|Le Poker]] : 2 joueurs minimum
* [[Boîte à jeux/La politaine|La Politaine]] : 4 joueurs
* [[Boîte à jeux/Pouilleux|Pouilleux]] : 2 à 8 joueurs
* [[Boîte à jeux/Le Président|Le Président]] : à partir de 3 joueurs
* [[Boîte à jeux/Le Tarot|Le Tarot]] : 3 à 5 joueurs
* [[Trou du Cul]] : à partir de 3 joueurs
* [[Boîte à jeux/Vitesse|Vitesse]] : 2 joueurs
|}
</div>
<div class="flex-content-half">
=== Jeux oraux ===
{|
| [[Image:SVG black joker.svg|100px]]
|
* [[Boîte à jeux/Snaps|Snaps]] : à partir de 2 joueurs
* [[Boîte à jeux/Le macaque|Le Macaque]] : à partir de 2 joueurs, idéalement de 8 à 12 joueurs
* [[Boîte à jeux/Mafia|Mafia]] : à partir de 5 joueurs, idéalement de 8 à 12 joueurs
* [[Les Loups-garous de Thiercelieux]] : de 8 à 18 joueurs
|}
=== Jeux de plateaux ===
{|
| [[Image:Crystal Project Package games board.png|100px]]
|
* [[Boîte à jeux/Bagh-Bandi|Bagh-Bandi]] : 2 joueurs
* [[Boîte à jeux/Le neutron|Le Neutron]] : 2 joueurs
* [[Boîte à jeux/Awalé|Awalé, awélé ou walé]] : 2 joueurs
* [[Boîte à jeux/Jeux de Nim|Jeux de Nim]] : 2 joueurs
* [[Boîte à jeux/Jeu du moulin|Jeu du moulin]] : 2 joueurs
|}
=== Jeux de dés ===
{|
| [[Image:Two red dice 01.svg|100px]]
|
* [[Boîte à jeux/10.000|10.000]] : à partir de 2 joueurs
* [[Boîte à jeux/421|421]] : 2 joueurs
* [[Boîte à jeux/PokerMenteur|Poker Menteur]] : à partir de 3 joueurs, idéalement 5 à 8 joueurs
* [[Boîte à jeux/Zanzi|Zanzi]] : à partir de 3 joueurs
* [[Boîte à jeux/Buckdice|Buckdice]] : à partir de 2 joueurs
* [[Boîte à jeux/Sept|Sept]] : à partir de 2 joueurs
* [[Boîte à jeux/Yams|Yams]] : à partir de 2 joueurs
* [[Boîte à jeux/TrenteDouze|Le Trente-douze]] : à partir de 2 joueurs
* [[Boîte à jeux/Perudo|Perudo]] : à partir de 2 joueurs
* [[Boîte à jeux/Le cul de chouette|Le cul de chouette]] : de 2 à 6 joueurs
|}
</div>
</div>
== Autres livres ==
* [[Jeu de go|Jeu de GO]]
* [[Apprendre à jouer aux échecs]]
[[Catégorie:Boîte à jeux (livre)|*]]
[[Catégorie:Jeux]]
5vheylj217ilhrwpxm0z2q2rrc39w9e
774902
774866
2026-10-11T18:49:54Z
Cso75
124351
Nouvelle page
774902
wikitext
text/x-wiki
{{Page de garde|image=Jeux.png|imagedesc=Jeux|description=
Il existe une multitude de jeux dans le domaine public et il pourrait être intéressant que le projet Wikimedia collecte les règles de ces jeux.
Ce Wikilivre '''boite à jeux''' s'inscrit dans cet objectif, en se limitant volontairement à un type de jeux.
Il s'agit de jeux de société pouvant se jouer autour d'une table avec un matériel relativement restreint :
<div style="border:1px solid #aaaaaa; padding:0.5em; margin:0.5em;">
* papier, ciseaux, règle et crayons,
* 50 pions blanc, 50 pions noirs, 20 pions rouges, 20 pions bleus et 20 pions verts,
* 2 jeux de 54 cartes avec un dos différent,
* 6 dés à six faces,
* 2 gobelets.
</div>
À ce matériel s'ajoutent les plateaux à imprimer qui font partie de ce Wikilivre.
''Ce wikilivre se trouve sur l'étagère [[Wikilivres:Étagère jeu|Jeu]].''
|avancement=Avancé
|cdu=
* {{CDU item|7|79}}
|versions=
{{Moteur}}
{{Version imprimable}}
}}
__NOTOC__
== Règles disponibles ==
<div class="flex-content">
<div class="flex-content-half">
=== Jeux de cartes ===
* [[Boîte à jeux/Vocabulaire|Vocabulaire des jeux de cartes]] : Cette page définit les mots couramment utilisés dans la plupart des jeux de cartes (couleur, atout, couper, se défausser...)
* [[Boîte à jeux/Jeu de la carte|Règles communes du jeu de la carte]] : Cette page explique le déroulement d'un tour de jeu (une levée) dans la plupart des jeux de carte.
{|
| [[Image:Crystal Clear app Cardgame.png|100px]]
|
* [[Boîte à jeux/L'ascenseur|L'Ascenseur]] : à partir de 3 joueurs
* [[Boîte à jeux/Le barbu|Le Barbu]] : 4 joueurs
* [[Boîte à jeux/La bataille corse|La Bataille Corse]] : à partir de 2 joueurs
* [[Boîte à jeux/Bataille_norvégienne|La Bataille Norvégienne]] : à partir de 2 joueurs
* [[Boîte à jeux/La belote|La Belote]] : 4 joueurs
* [[Boîte à jeux/Bonjour monsieur, bonjour madame|Bonjour monsieur, bonjour madame]]: 2 à 4 joueurs
* [[Boîte à jeux/Bouteille ou Yaniv|Bouteille ou Yaniv]] : à partir de 2 joueurs
* [[Boîte à jeux/Chkobba|Chkobba]] : 2, 4 ou 6 joueurs
<!--* [[Boîte à jeux/La crapette|La crapette]] : à partir de 2 joueurs-->
* [[Boîte à jeux/Cribbage|Cribbage]] : 2 à 4 joueurs
* [[Boîte à jeux/Dutch_pablo|Dutch ou Pablo]] : à partir de 2 joueurs
* [[Boîte à jeux/L'écarté|L'écarté]] : 2 joueurs
* [[Boîte à jeux/Le Flip|Le Flip]] : 2 joueurs
* [[Boîte à jeux/Gin rami|Gin rami]] : 2 joueurs
* [[Boîte à jeux/Le huit américain|Le Huit Américain]] : à partir de 3 joueurs
* [[Kems]] : 4 joueurs
* [[Boîte à jeux/Kilo de merde|Kilo de merde]] : à partir de 3 joueurs
* [[Boîte à jeux/La manille|La Manille]] : 4 joueurs
* [[Boîte à jeux/Poker|Le Poker]] : 2 joueurs minimum
* [[Boîte à jeux/Dutch_pablo|Pablo ou Dutch]] : à partir de 2 joueurs
* [[Boîte à jeux/La politaine|La Politaine]] : 4 joueurs
* [[Boîte à jeux/Pouilleux|Pouilleux]] : 2 à 8 joueurs
* [[Boîte à jeux/Le Président|Le Président]] : à partir de 3 joueurs
* [[Boîte à jeux/Le Tarot|Le Tarot]] : 3 à 5 joueurs
* [[Trou du Cul]] : à partir de 3 joueurs
* [[Boîte à jeux/Vitesse|Vitesse]] : 2 joueurs
|}
</div>
<div class="flex-content-half">
=== Jeux oraux ===
{|
| [[Image:SVG black joker.svg|100px]]
|
* [[Boîte à jeux/Snaps|Snaps]] : à partir de 2 joueurs
* [[Boîte à jeux/Le macaque|Le Macaque]] : à partir de 2 joueurs, idéalement de 8 à 12 joueurs
* [[Boîte à jeux/Mafia|Mafia]] : à partir de 5 joueurs, idéalement de 8 à 12 joueurs
* [[Les Loups-garous de Thiercelieux]] : de 8 à 18 joueurs
|}
=== Jeux de plateaux ===
{|
| [[Image:Crystal Project Package games board.png|100px]]
|
* [[Boîte à jeux/Bagh-Bandi|Bagh-Bandi]] : 2 joueurs
* [[Boîte à jeux/Le neutron|Le Neutron]] : 2 joueurs
* [[Boîte à jeux/Awalé|Awalé, awélé ou walé]] : 2 joueurs
* [[Boîte à jeux/Jeux de Nim|Jeux de Nim]] : 2 joueurs
* [[Boîte à jeux/Jeu du moulin|Jeu du moulin]] : 2 joueurs
|}
=== Jeux de dés ===
{|
| [[Image:Two red dice 01.svg|100px]]
|
* [[Boîte à jeux/10.000|10.000]] : à partir de 2 joueurs
* [[Boîte à jeux/421|421]] : 2 joueurs
* [[Boîte à jeux/PokerMenteur|Poker Menteur]] : à partir de 3 joueurs, idéalement 5 à 8 joueurs
* [[Boîte à jeux/Zanzi|Zanzi]] : à partir de 3 joueurs
* [[Boîte à jeux/Buckdice|Buckdice]] : à partir de 2 joueurs
* [[Boîte à jeux/Sept|Sept]] : à partir de 2 joueurs
* [[Boîte à jeux/Yams|Yams]] : à partir de 2 joueurs
* [[Boîte à jeux/TrenteDouze|Le Trente-douze]] : à partir de 2 joueurs
* [[Boîte à jeux/Perudo|Perudo]] : à partir de 2 joueurs
* [[Boîte à jeux/Le cul de chouette|Le cul de chouette]] : de 2 à 6 joueurs
|}
</div>
</div>
== Autres livres ==
* [[Jeu de go|Jeu de GO]]
* [[Apprendre à jouer aux échecs]]
[[Catégorie:Boîte à jeux (livre)|*]]
[[Catégorie:Jeux]]
9t665ytvkk9ohmfwei3ub0ddvq50kdk
Programmation LSE
0
15891
775031
761472
2026-10-12T11:27:07Z
~2026-54759-70
124717
/* Controles */ ajouté l'accent circonflexe manquant
775031
wikitext
text/x-wiki
{{EnTravaux}}
{{00}}
== Présentation ==
Le L.S.E. (Langage Symbolique d'Enseignement) a été créé par Yves Noyelle au début des années 1970 pour enseigner la programmation dans les lycées français. Entre 1968 et 1969, Monsieur Noyelle avait écrit le langage LSD (Langage Symbolique Didactique) pour les besoins de l'École supérieure d'électricité mais au cours d'un colloque tenu à Sèvres en 1970, il fut décidé d'installer quelques machines à titre expérimental dans des lycées. C'est pour ce projet que fut créé LSE, une évolution naturelle de LSD, E suivant D dans l'alphabet.
Cette documentation couvre le langage tel qu'il est défini dans sa version LSE-2000 (aussi nommé Phoenix, et parfois LSF) proposé par Luc Goulet.
Elle ne traitera pas de la proposition LSE-83 de [[w:Jacques Arsac|Jacques Arsac]] qui était une proposition de modernisation devenue obsolète avec l'introduction de LSE-2000 qui retient plusieurs éléments proposés par LSE-83 notamment au chapitre des boucles.
LSE-2000 ajoute de nombreuses fonctionnalités à la version AFNOR et en réforme d'autre.
Notez que les améliorations sur la norme de 1980 sont indiquées par un (LSE-2000).
LSE-2000 est à son ancêtre ce que les Basics modernes sont à ceux de 1970.
Tout d'abord parmi les modifications apportées par LSE-2000 sur la norme AFNOR, il y a l'instruction SI..ALORS SINON...FINSI qui remplace l'ancienne forme.
Il y a ensuite l'ajout de nombreuses formes de boucle qui s'ajoutent à la forme d'origine.
L'ajout de nouveaux types du genre « contenant », soit les types ENSEMBLE et QUEUE, qui s'ajoutent au seul type de ce genre existant, le type TABLEAU.
La distinction entre nombre réel et entier par la séparation des types ENTIER et NOMBRE.
L'ajout de nouveaux opérateurs tels que NI, ET QUE, NI QUE, OU QUE, ainsi que des opérateurs destinés à la manipulation de nouveaux types de données comme l'opérateur DANS.
Un nouvel opérateur conditionnel SELON-CHOISIR-SINON s'ajoute au préexistant SI-ALORS-SINON.
S'ajoute également l'instruction de branchement SELON..ALLER EN..SINON EN...
À cela s'ajoute des extensions de programmation orienté objet, ainsi que diverses améliorations à la bibliothèque standard.
Enfin il faut se souvenir que LSE est '''insensible''' à la casse, ainsi les noms PhoenixLSE, phoenixlse et pHoEnIxLsE seront reconnus comme étant la même chose.
Notez également que les numéros de ligne obligatoire sous LSE-80 bien que facultatifs sous LSE-2000 demeurent reconnus ce qui facilite la migration d'anciens logiciels.
Ils sont désormais traités comme des étiquettes lorsque des instructions mal-aimées comme ALLER EN sont utilisées.
== Généralités ==
Dans cette section nous aborderons certaines généralités concernant LSE: les conventions et tous les petits détails importants.
=== Les Commentaires ===
==== Ligne de commentaire ====
Pour créer un commentaire sous LSE plusieurs options sont possibles. Pour créer un commentaire sur une seule ligne, c'est à dire
qui se termine au retour de chariot à la fin de la ligne il faut placer un astérisque * en début de commentaire.
* CECI EST UN COMMENTAIRE SUR UNE LIGNE
==== Bloc de commentaire ====
Pour créer un bloc de commentaire, sur une ou plusieurs lignes, il faut faire précéder le texte du commentaire par (* tandis que
pour fermer ce bloc il faut taper *). De plus les commentaires peuvent être imbriqués.
(* CECI
EST
UN BLOC *)
=== Chaîne de texte ===
Pour former une chaîne on l'écrit entre une paire d'apostrophes, par exemple : <code>'Ceci est une chaîne LSE'</code>.
Si votre chaîne contient des apostrophes, il faut les doubler ; ainsi ils ne seront pas pris comme marqueurs de la fin mais bien comme caractère, par exemple on écrira <code>'Tableau d<nowiki>''</nowiki>entier'</code>.
Pour produire une chaîne à partir d'un code de caractère on le place entre une paire de point, par exemple <code>.65.</code> équivaudra à 'A'.
=== Les nombres ===
Les nombres réels peuvent être écris de deux façons essentiellement :
* soit sous la forme:10.1234
* soit sous la forme 1.01234E1
Les nombres entiers peuvent être exprimés dans différentes bases : la base binaire, la base octale, la base décimale et la base hexadécimale.
Exemples :
* en décimal on écrira 255
* en octal on écrira 0377
* en binaire on écrira 0b11111111
* en hexadécimal on écrira 0xFF
=== Les angles ===
Les angles sont '''toujours''' exprimés en radian pour l'ensemble des fonctions trigonométriques de la bibliothèque standard.
=== Les fins de lignes ===
Chaque instruction LSE doit se terminer par un saut de ligne ou par son alter ego le point-virgule. Si on veut écrire sur une même ligne plusieurs instructions on écrira entre elle des point-virgule.
Dans le cas où une expression deviendrait trop longue il est possible de l'écrire sur plusieurs lignes en inscrivant au bout de
chaque ligne un \ ce qui indiquera à LSE d'ignorer le saut de ligne.
X←SI U # K ALORS 1.0 SINON \
SI W = P ALORS 2.1 SINON \
SI N-3 <= 17 ALORS 3.141592 SINON 0 FINSI \
FINSI \
FINSI
== Types et constantes ==
=== Types prédéfinis===
'''BOOLEEN'''<var1>,<var2>,...
<blockquote>Ce type permet de déclarer une variable booléenne, pouvant prendre les valeurs '''VRAI''' ou '''FAUX'''.</blockquote>
'''CARACTERE'''<var1>,<var2>,...
<blockquote>Ce type permet de déclarer une variable de type caractères.</blockquote>
'''CHAINE'''<var1>,<var2>,...
<blockquote>Ce type permet de déclarer une chaîne de caractères.</blockquote>
'''ENTIER'''<var1>,<var2>,...(LSE2000)
<blockquote>Ce type permet de déclarer une variable entière.</blockquote>
'''ENSEMBLE DE''' <type><var1>[dim],<var2>,... (LSE2000)
<blockquote>Ce type permet de déclarer un ensemble composé d'éléments du type <type>.<br><code>ENSEMBLE DE CHAINE ''UNENSEMBLE''[3]← {'bleu', 'blanc', 'rouge'}</code></blockquote>
'''FILE DE''' <type> <var1>[dim], <var2>...(LSE2000)
<blockquote>Ce type permet de déclarer une file dont la longueur est définie par <dim> et composé d'éléments du type <type>.Une FILE est une structure de donnée du type PIPE, premier inséré, premier extrait.<br><code>FILE DE NOMBRE ''UNEFILE''[5]</code></blockquote>
'''PILE DE''' <type> <var1>[dim], <var2>...(LSE2000)
<blockquote>Ce type permet de déclarer une pile dont la profondeur est définie par <dim> et composé d'éléments du type <type>. Une PILE esty une structure de donnée du type PIDE, premier inséré, dernier extrait.<br><code>PILE DE NOMBRE ''UNEPILE''[5]</code></blockquote>
'''NOMBRE'''<var1>,<var2>,...
<blockquote>Ce type permet de déclarer une variable numérique (flottant).C'est un autre nom pour REEL.</blockquote>
'''PROCEDURE @'''<identificateur>(<liste de params> ) [<type>][ '''LOCAL''' <params locaux>]...(LSE2000)
<blockquote>Ce type permet de déclarer une variable désignant une procédure à ne pas confondre avec une déclaration de procédure à proprement parler. La distinction se fait par le fait que le nom de la variable est précédé par un arobase (@) tandis que dans le cas d'une déclaration le nom de la procédure est précédé par une perluette (&). Pour plus d'information sur les procédures consulter le chapître Procédures.<br>PROCEDURE @VARPROC(ENTIER PARAM):NOMBRE LOCAL PARAM</blockquote>
'''REEL'''<var1>,<var2>,...
<blockquote>Ce type permet de déclarer une variable numérique (flottant). C'est un autre nom pour NOMBRE.</blockquote>
'''TABLEAU DE''' <type> <var1>[dim,...], <var2>...
<blockquote>Ce type permet de déclarer un tableau multi-dimensionné composé d'éléments du type <type>.<br><code>TABLEAU DE NOMBRE ''ESPACETEMPS''[5,5,5,5]</code></blockquote>
=== Qualificateurs===
'''NATUREL''' (LSE2000)
<blockquote>Ce qualificateur s'applique au type de valeur entière et force l'usage de nombre entier exclusivement positif.<br><code>ENTIER NATUREL ''MAVARIABLE''</code></blockquote>
'''RELATIF''' (LSE2000)
<blockquote>Ce qualificateur s'applique au type de valeur entière et force l'usage de nombre entier signé (pouvant être positif ou négatif). Par défaut les types de valeur entière sont RELATIF il est inutile d'utiliser ce qualificatif dans une déclaration. Il existe pour effectuer des conversions de type.</blockquote>
NB: Les anciennes dénominations AVEC et SANS SIGNE ont été remplacées par cette forme plus compacte et naturelle.
'''ÉTENDU''' (LSE2000)
<blockquote>Ce qualificateur s'applique aux types RÉEL et NOMBRE et force l'usage de leur forme étendu.<br><code>RÉEL ÉTENDU ''MAVARIABLE''</code></blockquote>
=== Types construits===
'''TYPE''' <montype> '''EST''' <type>(LSE2000)
<blockquote>Permet de déclarer un type construit nommé <montype> comme équivalent de sa déclaration.<br><code>TYPE ''MONTYPE'' EST TABLEAU DE ENTIER NATUREL[32]</code></blockquote>
'''STRUCTURE''' <montype> '''EST''' <declarations>FIN STRUCTURE(LSE2000)
<blockquote>Permet de déclarer un type structure nommé <montype> comme équivalent de sa déclaration.
'''STRUCTURE''' ''MASTRUCT'' '''EST''' \
'''CHAINE''' iChaine \
'''ENTIER NATUREL''' iTableau[32] \
'''FIN STRUCTURE'''
</blockquote>
=== Constantes ===
'''.E.'''
<blockquote>Constante du nombre d'Euler.</blockquote>
'''.FAUX.'''
<blockquote>Valeur booléenne fausse.</blockquote>
'''.GAMMA.'''
<blockquote>Constante d'Euler-Mascheroni.</blockquote>
'''.PHI.'''
<blockquote>Constante du nombre d'or.</blockquote>
'''.PI.'''
<blockquote>Constante de Pythagore (π).</blockquote>
'''.PUN.'''
<blockquote>Constante Pas Un Nombre.</blockquote>
'''.VRAI.'''
<blockquote>Valeur booléenne vraie.</blockquote>
'''.###.'''
<blockquote>Constante textuelle. Les # sont remplacés par le code du caractère voulu. Par exemple .32.</blockquote>
== Opérateurs et Expressions ==
=== Opérateurs arithmétiques ===
← Opérateur d'assignation
<blockquote><variable>←<expression>
Opérateur d'assignation, transfère la valeur de l'expression «expression» dans la variable «variable».
Traditionnellement le caractère «←» correspondait au code 95, sur les systèmes ne disposant pas de ce jeu de caractère, il est remplacé par <-. En Unicode on utilisera en UTF-16 $2190 ou en UTF-8 $E2 $86 $90.</blockquote>
+ Opérateur d'addition
<blockquote><nombre>←<expression1> + <expression2><br />Opérateur d'addition, additionne les expressions «expression1» et «expression2». Le résultat est une valeur numérique.</blockquote>
- Opérateur de soustraction
<blockquote><nombre>←<expression1> - <expression2><br />Opérateur de soustraction, Soustrait l'expression «expression2» de «expression1». Le résultat est une valeur numérique.</blockquote>
* Opérateur de multiplication
<blockquote><nombre>←<expression1> * <expression2><br />Opérateur de multiplication, multiplie les expressions «expression1» et «expression2». Le résultat est une valeur numérique.</blockquote>
/ Opérateur de division
<blockquote><nombre>←<expression1> / <expression2><br />Opérateur de division, Divise l'expression «expression1» par «expression2». Le résultat est une valeur numérique</blockquote>
% Opérateur modulo
<blockquote><nombre>←<expression1> % <expression2>
Opérateur de modulo, Divise l'expression «expression1» par «expression2» et retourne le reste. Le résultat est une valeur numérique</blockquote>
^ Opérateur de puissance
<blockquote><nombre>←<expression1> ^ <expression2><br />Opérateur de puissance, élève à la puissance «expression2» la valeur de l'expression «expression1». Le résultat est une valeur numérique.</blockquote>
=== Opérateurs booléens ===
'''ET''' Conjonction booléenne<blockquote>
<booléen>←<expression-booléenne1> '''ET''' <expression-booléenne2><br />
Opérateur logique bit à bit, effectue la conjonction des expressions «expression1» et «expression2». Le résultat est une valeur booléenne.</blockquote>
'''ET QUE''' Conjonction booléenne (LSE2000)<blockquote>
<booléen>←<expression-booléenne1> '''ET QUE''' <expression-booléenne2><br />
Opérateur logique bit à bit, effectue la conjonction des expressions «expression1» et «expression2». Le résultat est une valeur booléenne. Si «expression1» est fausse «expression2» n'est pas évaluée et une valeur fausse est retournée.</blockquote>
'''NI''' Disjonction booléenne inverse (LSE2000)<blockquote>
<booléen>←<expression-booléenne1> '''NI''' <expression-booléenne2><br />
Opérateur booléen, effectue la disjonction inversée des expressions «expression1» et «expression2». Est équivalent à NON( exp1 OU exp2 ). Le résultat est une valeur booléenne.</blockquote>
'''NI QUE''' Disjonction booléenne inverse (LSE2000)<blockquote>
<booléen>←<expression-booléenne1> '''NI QUE''' <expression-booléenne2><br />
Opérateur booléen, effectue la disjonction inversée des expressions «expression1» et «expression2». Si «expression1» est vraie «expression2» n'est pas évaluée et une valeur fausse est retournée. Le résultat est une valeur booléenne.</blockquote>
'''NON''' Négation booléenne<blockquote>
<booléen>← '''NON''' <expression-booléenne><br />
Opérateur booléen, effectue la négation de l'expression «expression». Le résultat est une valeur booléenne.</blockquote>
'''OU''' Disjonction booléenne<blockquote>
<booléen>←<expression-booléenne1> OU <expression-booléenne2><br />
Opérateur booléen, effectue la disjonction des expressions «expression1» et «expression2». Le résultat est une valeur booléenne.</blockquote>
'''OU QUE''' Disjonction booléenne (LSE2000)<blockquote>
<booléen>←<expression-booléenne1> '''OU QUE''' <expression-booléenne2>
Opérateur booléen, effectue la disjonction des expressions «expression1» et «expression2». Le résultat est une valeur booléenne. Si «expression1» est vraie «expression2» n'est pas évaluée et une valeur vraie est retournée.</blockquote>
'''OX''' Différence symétrique booléenne<blockquote>
<booléen>←<expression-booléenne1> OX <expression-booléenne2><br />
Opérateur Ou eXclusif booléen, effectue la différence symétrique des expressions «expression1» et «expression2». Le résultat est une valeur booléenne.</blockquote>
=== Opérateurs comparatifs ===
= Egalité<br />
<blockquote><booléen>←<expression1> = <expression2><br />Compare les expressions «expression1» et «expression2». Le si les deux expressions sont de valeur égale le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
< Plus Petit Que<br />
<blockquote><booléen>←<expression1> < <expression2><br />Compare les expressions «expression1» et «expression2». Le si la première expression a une valeur plus petite que la seconde le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
> Plus Grand Que<br />
<blockquote><booléen>←<expression1> > <expression2><br />Compare les expressions «expression1» et «expression2». Le si la première expression a une valeur plus grande que la seconde le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
<nowiki>#</nowiki> Différent de
<blockquote><booléen>←<expression1> # <expression2><br />Compare les expressions «expression1» et «expression2». Le si les deux expressions sont de valeur différente le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
<= Plus Petit ou Egal<br />
<blockquote><booléen>←<expression1> <= <expression2><br />Compare les expressions «expression1» et «expression2». Le si la première expression a une valeur plus petite ou égale à la seconde le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
>= Plus Grand ou Egal<br />
<blockquote><booléen>←<expression1> >= <expression2><br />Compare les expressions «expression1» et «expression2». Le si la première expression a une valeur plus grande ou égale à la seconde le résultat est une valeur booléenne VRAI autrement c'est FAUX.</blockquote>
=== Opérateurs textuels ===
'''!''' Concaténation<br />
<blockquote><chaîne>←<chaîne1> ! <chaîne2><br />Concatène les chaînes «chaîne1» et «chaîne2».</blockquote>
=== Opérateurs conditionels ===
'''SI''' ... '''ALORS''' ... '''SINON''' ... '''IS'''<br />
<blockquote><valeur>←SI<expression booléenne> ALORS <expression1> SINON <expression2>IS<br />Retourne <expression1> si <expression booléenne> est VRAI et <expression2> sinon.</blockquote>
'''SELON''' ... '''CHOISIR''' ... '''SINON''' ...'''FIN'''<br />
<blockquote><valeur>← SELON <expression entière> CHOISIR <expression1>[,<expression2>,...] SINON <expression0>FIN<br />Retourne <expression1> si la valeur de <expression entière> est 0, <expression2> si elle est 1, etc. et <expression0> sinon.<br><code>''MAVALEUR''←SELON ''X'' CHOISIR 3.25,4.67,8,97 SINON 0.1 FIN</code></blockquote>
=== Opérateurs sur les ensembles ===
! Concaténation (LSE2000)<br />
<blockquote><ensemble>←<ensemble1> ! <ensemble2><br />Concatène les ensembles « ensemble1» et « ensemble2».</blockquote>
'''DANS''' inclusion (LSE2000)<br />
<blockquote><booléen>←< élément> DANS <ensemble><br />Détermine si l'élément est présent dans l'ensemble.</blockquote>
=== Priorité des opérateurs ===
L'ordre le plus petit indique une priorité plus élevé.
# NON, DANS, ↑, SI-ALORS-SINON, SELON-CHOISIR-SINON<br />
# /, %, *<br />
# +, -<br />
# =, <, <=, >, >=, #
# ET, ET QUE
# OU, OU QUE, NI, NI QUE, OX, !<br />
== Contrôles ==
=== Branchement ===
'''ALLER EN''' <#ligne>
<blockquote>Continue l'exécution à la ligne spécifier par <#ligne>, consistait en un numéro de ligne à l'origine.</blockquote>
'''SELON''' <expression> '''ALLER EN''' <#ligne1>[,<#ligne2>, ...] ['''SINON EN''' <#ligne>]
<blockquote>Selon la valeur de l'expression l'exécution continue à la ligne spécifiée par l'une des étiquettes, consistait en un numéro de ligne à l'origine. Si la valeur de l'expression est 0 c'est la première étiquette qui est prise, si elle vaut 1 alors c'est la seconde ainsi de suite. L'instruction optionnelle SINON permet de spécifier un branchement dans le cas où la valeur de l'expression déborde des choix. Si on omet cette dernière l'exécution continuera son cours normal à la ligne suivante.</blockquote>
'''SI''' <expression booléenne> '''ALORS''' <bloc1> [ '''SINON''' <bloc2> ] '''FIN SI''' <br/>
<blockquote>Si l'expression booléenne <expression booléenne> est VRAI alors les instructions du bloc <bloc1> sont exécutées sinon ce sont celles du bloc <bloc2> qui seront exécutées. Pour mettre plusieurs instructions dans une branche, on peut utiliser un bloc '''DEBUT'''...'''FIN''' comme jadis ou simplement écrire les instructions en les faisant suivre d'un séparateur ; <br/><br/><code>'''SI''' U=V '''ALORS''' U←0;V←255; '''SINON''' U←U+1;V←V-1; '''FIN SI'''</code>
</blockquote>
'''Attention''' : Ne confondez pas l'instruction de contrôle avec l'opérateur SI-ALORS-[SINON]. Consultez la section sur les opérateurs pour de plus amples informations sur ce sujet.
'''EVALUER''' <expression1> ['''QUAND''' <expression1> <bloc>]...] '''QUAND AUTRE''' <bloc> '''FIN EVALUER'''
<blockquote>Compare la ou les expressions à la droite de EVALUER avec celle ou celles à la droite des QUAND et exécute le bloc d'instruction du premier QUAND dont les expressions correspondent à celles du EVALUER. Si aucune correspondance n'est trouvée alors c'est le bloc du QUAND AUTRE qui est exécuté.
'''EVALUER''' X
'''QUAND''' 255
'''AFFICHER''' 'QUAND 255 255'
'''QUAND TOUS'''
'''AFFICHER''' 'QUAND TOUS 255'
'''QUAND''' 255 '''TOUS'''
'''AFFICHER''' 'QUAND 255 TOUS'
'''QUAND AUTRE'''
'''AFFICHER''' 'QUAND AUTRE'
'''FIN EVALUER'''
Pour évaluer si des conditions sont vraies ou fausses, il faut mettre en paramètre du '''ÉVALUER''' l'une des constantes booléennes ('''.VRAI.''' ou '''.FAUX.''') ainsi les expressions des instructions QUAND seront comparées avec cette valeur. Par exemple :
'''EVALUER''' .VRAI.
'''QUAND COND0'''
'''AFFICHER''' 'QUAND COND0 est vrai'
'''QUAND COND1'''
'''AFFICHER''' 'QUAND COND1 est vrai'
'''QUAND COND2'''
'''AFFICHER''' 'QUAND COND3 est vrai'
'''QUAND AUTRE'''
'''AFFICHER''' 'QUAND aucune des condition n<nowiki>''</nowiki>est vrai'
'''FIN EVALUER'''
Pour évaluer l'une après l'autre si une suite de conditions sont vraies ou fausses, il faut mettre '''EN CHAÎNE''' suite au '''ÉVALUER'''. Quand une expression est vraie alors le bloc QUAND est exécuté avant d'évaluer le QUAND suivant. Par exemple :
'''EVALUER EN CHAINE'''
'''QUAND COND0'''
'''AFFICHER''' 'COND0 est vrai'
'''QUAND COND1'''
'''AFFICHER''' 'COND1 est vrai'
'''QUAND COND2'''
'''AFFICHER''' 'COND2 est vrai'
'''FIN EVALUER'''
Ce code exprimé sous la forme d'une suite de '''SI-ALORS''' correspond à la séquence d'instructions suivante :
'''SI''' COND0 '''ALORS''' '''AFFICHER''' 'COND0 est vrai'
'''SI''' COND1 '''ALORS''' '''AFFICHER''' 'COND1 est vrai'
'''SI''' COND2 '''ALORS''' '''AFFICHER''' 'COND2 est vrai'
Dans le cas d'un '''EVALUER EN CHAINE''' la présence d'un QUAND AUTRE produira une erreur de syntaxe.
</blockquote>
'''TOUS'''
<blockquote>Indique dans une instruction EVALUER-QUAND que l'expression correspondante du QUAND peut être n'importe quoi. Cela n'a pas d'importance pour ce cas.</blockquote>
=== Boucles ===
'''POUR''' ''var'' ← ''expression1'' [ '''PAS''' ''expression2'' ] '''JUSQUA''' ''expression3'' '''FAIRE''' ''bloc'' '''BOUCLER''' (LSE2000)
'''POUR''' ''var'' ← ''expression1'' [ '''PAS''' ''expression2'' ] '''TANT QUE''' ''expression3'' '''FAIRE''' ''bloc'' '''BOUCLER''' (LSE2000)
<blockquote>
Exécute en boucle la ou les instructions du bloc jusqu'à ce que les conditions de sortie de boucle soit atteintes.<br/>
'''PAS''' indique le pas d'incrémentation de ''var'' (décrémentation si négatif) un pas de 0 provoque une boucle sans fin.<br/>
'''JUSQUA''' ''expression3'' indique la valeur que doit atteindre ''var'' pour terminer la boucle<br/>
'''TANT QUE''' indique d'arrêter la boucle quand le résultat de ''expression3'' est FAUX<br/>
</blockquote>
'''TANT QUE''' ''expression'' '''FAIRE''' ''bloc'' '''BOUCLER''' (LSE2000)
<blockquote>
Effectue les opérations comprises dans le bloc délimité par les mots-clefs '''FAIRE''' et '''BOUCLER''' tant que la valeur booléenne ''expression'' est vraie ou qu'une instruction '''FINIR''' est rencontrer.</blockquote>
'''FAIRE''' ''bloc'' '''BOUCLER''' (LSE2000)
<blockquote>Boucle sans fin qui peut être interrompue par un appel à '''FINIR'''. </blockquote>
'''FAIRE''' ''bloc'' '''TANT QUE''' ''expression'' (LSE2000)
<blockquote>Effectue les opérations comprises dans le bloc délimité par les mots-clefs '''FAIRE''' et '''TANT QUE''' tant que la valeur booléenne ''expression'' est vraie ou qu'une instruction '''FINIR''' est rencontrée.</blockquote>
'''FAIRE''' ''#ligne'' '''POUR''' ''var'' ←''expression'' [ '''PAS''' ''expression'' ] '''JUSQUA''' ''expression''<br/>'''FAIRE''' ''#ligne'' '''POUR''' ''var'' ←''expression'' [ '''PAS''' ''expression'' ] '''TANT QUE''' ''expression''
<blockquote>
Exécute en boucle la ou les instructions qui suivent cette ligne jusqu'à la ligne pointée par étiquette #ligne inclusivement, jusqu'à ce que les conditions de sortie de boucle soit atteintes. Cette boucle est là pour simplifier le portage du code ancien, il est préférable de l'éviter pour du nouveau code.<br/>
'''PAS''' indique le pas d'incrémentation de ''var'' (décrémentation si négatif) un pas de 0 provoque une boucle sans fin.<br/>
'''JUSQUA''' ''expression3'' indique la valeur que doit atteindre ''var'' pour terminer la boucle<br/>
'''TANT QUE''' indique d'arrêter la boucle quand le résultat de <expression> est FAUX<br/>
</blockquote>
=== Autres ===
'''BOUCLER''' (LSE2000)
<blockquote>Reprend la boucle courante et passe au pas suivant si c'est une boucle POUR.</blockquote>
'''BRISER''' (LSE2000)
<blockquote>Brise la boucle courante.</blockquote>
'''BRISER QUAND''' <expression booléenne>(LSE2000)
<blockquote>Brise la boucle courante si l'expression <expression booléenne> est vraie.</blockquote>
'''RESULTAT''' <expression>
<blockquote>Retourne un résultat d'une fonction.</blockquote>
'''RETOUR'''
<blockquote>Retour d'une procédure.</blockquote>
'''TERMINER'''
<blockquote>Termine le programme.</blockquote>
== Exceptions ==
À faire...
=== Déclaration d'un bloc ===
'''ESSAYER''' (LSE2000)
<blockquote>Défini le bloc associé à l'auditeur.</blockquote>
'''ERREUR''' (LSE2000)
<blockquote>Défini un auditeur pour toutes les exceptions lancées à l'intérieur du bloc.</blockquote>
=== Lancement des exceptions ===
'''LANCER''' ''exception'' (LSE2000)
<blockquote>Lance une exception.</blockquote>
== Procédures ==
=== Déclaration ===
'''PROCEDURE &'''<identificateur>(<liste de params> ) [<type>][ '''LOCAL''' <params locaux>] (LSE2000)<br/>
<blockquote>Un paramètre peut être passé par référence, par valeur ou par descripteur. Le paramètre formel est passé par valeur s'il figure dans la liste suivant le mot-clef LOCAL comme dans les versions précédentes de LSE. Mais il est aussi possible de déclarer un paramètre comme local en lui ajoutant # en préfixe. Sinon par défaut les paramètres sont passés par référence.
On sort d'une procédure en invoquant '''RETOUR''' ou encore '''RESULTAT''' si cette dernière doit retourner une valeur. La valeur de retour est établie selon la syntaxe: '''RESULTAT''' <expression>. Le type de la valeur de retour est spécifié à la suite des paramètres dans la déclaration de la procédure.
'''PROCEDURE''' &sontEgaux('''ENTIER''' A, '''ENTIER''' B, '''ENTIER''' C) '''BOOLEEN LOCAL''' A,B
C←A-B
'''RESULTAT''' A=B
'''FIN'''
Ou en utilisant la notation # pour les paramêtres locaux (LSE2000)
'''PROCEDURE''' &sontEgaux('''ENTIER''' #A, '''ENTIER''' #B, '''ENTIER''' C) BOOLEEN
C←A-B
'''RESULTAT''' A=B
'''FIN'''
</blockquote>
=== Invocation ===
Pour invoquer une procédure on précède son identificateur par & comme dans l'exemple suivant.
<blockquote><code>unRésultat ← &MaProcédure(unParam, etUnAutre)</code></blockquote>
=== Variable ===
Comme indiqué dans le chapitre sur les types il existe une type PROCEDURE qui permet de déclarer une variable désignant une procédure. Ce qui permet par exemple, de passer une procédure en paramètre à une autre, par exemple si nous avons la procédure &TRI_PYRAMIDALE on peut lui passer une procédure de comparaison en paramètre ce qui lui permettra de trier n'importe quoi.
La distinction entre une variable et une procédure se fait par le fait que le nom de la variable est précédé par un arobase (@) tandis que dans le cas d'une déclaration le nom de la procédure est précédé par une esperluette (&). Lorsque l'on voudra invoquer une procédure désignée par une variable il suffira de précéder son nom par une esperluette.
<blockquote><code>&MaVariableProcédure</code></blockquote>
Voici un exemple d'utilisation d'une variable de type PROCEDURE :
<blockquote>
* DECLARATION VARIABLE ET PROCEDURE
PROCEDURE @VARPROC(ENTIER PARAM):NOMBRE LOCAL PARAM
PROCEDURE &PROCVAR(ENTIER PARAM):NOMBRE LOCAL PARAM
RESULTAT .PI. * PARAM<br>FIN<br><br>* ASSIGNATION
VARPROC ← PROCVAR
* INVOCATION<br>AFFICHER[/,'APPEL PAR LA VARIABLE TYPE PROCEDURE',/]
AFFICHER[4X,'RESULTAT = ',U,/] &VARPROC( 255 )
</blockquote>
/!\ Attention il faut préalablement assigner la variable avant d'invoquer celle-ci !
== Fonctions intégrées ==
=== AFFICHER ===
'''AFFICHER'''() Afficher sur la console.
AFFICHER [ <spécification de format>,... ] <valeur> [expression,...]
Liste des formats possibles:
;/:formate un passage au début de la ligne suivante, retour de chariot puis saut de ligne.
;B:formate un entier sous une forme binaire (LSE-2000)
;C:formate un retour au début de ligne, retour de chariot.
;F:formate un nombre sous forme décimale (virgule flottante ou entier).
;E:formate un nombre avec exposant (virgule flottante ou entier). une chaîne se formate elle même.
;H:formate un entier sous forme hexadécimale (LSE-2000)
;L:formate un passage à la ligne suivante.
;O:formate un entier sous forme octale (LSE-2000)
;U:formate une valeur quelconque.
;X:formate un espace.
;'..': incorpore une chaîne dans le formatage
Si un facteur de répétition numérique est donné, l'effet obtenu est le même que si la spécification de format qui suit avait été répétée autant de fois. Le facteur de répétition se pause avant le format. Par exemple «afficher [3/] » ici le facteur de répétition 3 indique d'il faut sauter trois lignes.
Si une étoile (*) est donnée comme facteur de répétition, la prochaine expression est prise comme facteur de répétition.
{{Cadre code|Exemple|
10 FAIRE 20 POUR I<-1 JUSQUA 10<br />
20 AFFICHER [/,*X,*'*'] I,11-I<br />
30 TERMINER
affiche:
**********
*********
********
*******
******
*****
****
***
**
*
}}
{{Cadre code|Exemple 2|
A<-1;B<-2;C<-3;D<_A+B+C;AFFICHER [/,'(+ ',3U,') --> ',U] A,B,C,D
affiche:
(+ 1 2 3 ) --> 6
}}
{{Cadre code|Exemple 3|
AFFICHER [/,5X,5'Q',/,5'-*']
affiche:
QQQQQ
-*-*-*-*-*
}}
=== LIRE ===
'''LIRE'''() Lire depuis le terminal<br/>LIRE...
== Programmation Orientée Objet ==
'''CLASSE'''
<blockquote>Type permettant de déclarer une classe.</blockquote>
<pre>
CLASSE maClasse
type identificateur
:
METHODE mamethode(params formels):type_de_retour LOCAL ...
FIN CLASSE
</pre>
'''CECI'''
<blockquote>Variable réservée qui désigne l'objet courant.</blockquote>
+ à venir
== Fonctions Standards ==
PS: pas encore révisé pour LSE2000<br />'''ABS'''() Valeur absolue<br /><nombre> ← ABS(<expression>)
Retourne la valeur absolue de l'expression numérique «expression».
'''ALE'''() Valeur aléatoire<br /><nombre> ← ALE(<nombre>)
Retourne une valeur une valeur « pseudo-aléatoire », dépendante du paramètre, est retournée. Entre 0.0 et 1.0 si arg = 0.
'''ALEE'''() Valeur aléatoire entière (LSE2000)<br /><entier> ← ALE(<entier>)
Retourne une valeur une valeur « pseudo-aléatoire », dépendante du paramètre, est retournée. Entre 0 et 2^32 si arg = 0.
'''AROND'''() Valeur arrondie (LSE2000)<br /><nombre> ← ATG(<nombre>)<br />Retourne la valeur arrondie d'un nombre.
'''ATG'''() Arc tangente<br /><nombre> ← ATG(<nombre>)<br />Retourne la valeur de l'arc tangente de l'expression numérique « expression ».
'''ATT'''() Attention<br /><nombre> ← ATT()<br />Retourne normalement 0. Quand l'utilisateur tape Ctrl-A, ATT() retourne 1 (une fois), puis de nouveau 0, jusqu'à ce que l'utilisateur tape de nouveau Ctrl-A. Ça permet d'envoyer un signal traitable par le programme. (ESCape envoie un signal au système qui interrompt le programme).
'''CCA'''() Conversion en chaîne<br /><chaîne> ← CCA( <expression-arithmétique> )<br />Converti l'expression arithmétique en sa représentation textuelle.
'''CNB'''() Conversion en nombre<br /><nombre> ← CNB( <expression-chaîne>, <debut> [, <fin>] )<br />Converti la sous-chaîne délimitée par et en nombre. Si le paramètre est omis la conversion se fait jusqu'à la fin de la chaîne.
'''COS'''() Cosinus<br /><nombre> ← COS(<expression>)<br />Retourne la valeur du cosinus de l'expression numérique «expression».
'''DAT'''() Date<br /><chaîne> ← DAT()<br />Retourne la date courante sous la forme "JJ/MM/AA HH:MM:SS".
'''DIS'''() secteur disque<br /><chaîne> ← DIS( <nombre> )<br />Retourne le contenu d'un secteur du disque dur sous forme d'une chaîne de 256 caractères.
'''ENT'''() partie entière<br /><nombre> ← ENT( <nombre> )<br />Retourne la partie entière du nombre.
'''EQC'''() équivalent chaîne<br /><chaîne> ← EQC( <nombre> )<br />Retourne dans une chaîne le caractère de code ASCII indiqué par le nombre.
'''EQN'''() équivalent nombre<br /><entier> ← EQN( <chaîne>,[<entier>] )<br />Retourne le code ASCII du premier caractère de la chaîne.
'''ETL'''() Et logique<br /><nombre> ← ETL( <nombre> , <nombre> )<br />Retourne le nombre résultant du ET logique, bit à bit des deux nombres. ETL(10,7)=2 car 01010 ETL 00111 = 00010
'''EXP'''() valeur de l'exponentielle<br /><nombre> ← EXP( <nombre> )<br />Retourne la valeur de l'exponentiel du <nombre> soit, e à la puissance <nombre>.
'''EXP2'''() valeur de l'exponentielle(LSE2000)<br /><nombre> ← EXP( <nombre> )<br />Retourne la valeur de l'exponentiel base 2 du <nombre> soit, 2 à la puissance <nombre>.
'''GRL'''() Groupe de lettres<br /><chaîne> ← GRL( <chaîne> , <nombre> [ , <variable> ] )<br />Extrait de la chaîne commençant à la position indiquée une sous-chaîne composée uniquement de lettres. Si une variable est donnée, elle sera affectée par la position du premier caractère qui n'est pas une lettre, ou la longueur de la sous-chaîne plus un.
'''ICH'''() Inverser chaîne<br /><chaîne> ← ICH( <chaîne> )<br />Inverse l'ordre des caractères dans la chaîne.
'''LG2'''() Logarithme base 2(LSE2000)<br /><nombre> ← LG2( <nombre> )<br />Retourne le logarithme base 2 du nombre.
'''LGN'''() Logarithme népérien<br /><nombre> ← LGN( <nombre> )<br />Retourne le logarithme Népérien du nombre.
'''LGR'''() Longueur de la chaîne<br /><nombre> ← LGR( <chaîne> )<br />Retourne la longueur de la chaîne.
'''MCH'''() Modifier chaîne<br /><chaîne> ← MCH( <chaîne>, <nombre>, <nombre>,<chaîne> )<br />Retourne une nouvelle <chaîne>, en remplaçant la sous-chaîne commençant à la position indiquée par <nombre> et finissant à la position indiquée par l'<expression>, qui peut être un nombre, ou une chaîne auquel cas la position de fin est le premier caractère de <chaîne> qui est dans l'<expression>. Si une <variable> est donnée, la position de fin lui est assignée.
{{Cadre code|exemple|
CHAINE A;A←'BONJOUR MONSIEUR, COMMENT ALLEZ VOUS?'<br />
AFFICHER A ; AFFICHER MCH(A,9,8,'MADAME')
BONJOUR MONSIEUR, COMMENT ALLEZ VOUS?
BONJOUR MADAME, COMMENT ALLEZ VOUS?
}}
'''OUL'''() Ou logique<br /><nombre> ← OUL( <nombre> , <nombre> )<br />Retourne le nombre résultant du OU logique, bit à bit des deux nombres.
OUL(10,7)=15 car 01010 OUL 00111 = 01111
'''OXL'''() Ou exclusif logique<br /><nombre> ← OXL( <nombre> , <nombre> )<br />Retourne le nombre résultant du OU EXCLUSIF logique, bit à bit des deux nombres. OXL(10,7)=13 car 01010 OXL 00111 = 01101
'''POS'''() Position<br /><nombre> ← POS( <chaîne1> , <nombre> , <chaîne2> )<br />Retourne la position de la sous-chaîne <chaîne2< dans la <chaîne1< en commençant à partir de la position indiquée. La première position dans une chaîne est 1.
'''PTR'''() Pointeur<br /><nombre> ← PTR( <chaîne> , <nombre> [ , <expression> ] )<br />Si l'expression optionnelle n'est pas donnée, alors PTR(<chaîne>,<nombre>) retourne la valeur assignée au paramètre variable dans GRL : la position du premier caractère non alphabétique après la position indiquée dans la chaîne. Si l'expression optionnelle est donnée, PTR retourne la valeur assignée au paramètre variable dans SCH : la position du première caractère suivant la sous-chaîne extraite par SCH(<chaîne>,<nombre>,<expression>).
'''RAC'''() Racine carrée<br /><nombre> ← RAC( <nombre> )<br />Retourne la racine carrée du nombre.
'''RCC'''() Relation comparative chaînes<br /><nombre> ← RCC( <chaîne1>,<chaîne2> [,<chaîne3>] )<br />Avec 2 arguments, compare deux chaînes et retourne -1,0,+1 selon que <code>chaîne1<chaîne2</code> ou <code>chaîne1=chaîne2</code> ou <code>chaîne1>chaîne2</code>, respectivement. Avec 3 arguments, le troisième argument donne l'ordre lexicographique que l'on veut. <chaîne3> doit être une chaîne de 256 caractères.
{{Cadre code|Exemple|
10 CHAINE CROISSANT,DECROISSANT;CROISSANT←' ';DECROISSANT←' '<br />
20 FAIRE 24 POUR I←0 JUSQUA 255<br />
22 CROISSANT←CROISSANT!EQC(I)<br />
24 DECROISSANT←DECROISSANT!EQC(255-I)<br />
30 AFFICHER ['CROISSANT: ',U,/]RCC('ABC','DEF',CROISSANT)<br />
40 AFFICHER ['DECROISSANT: ',U,/]RCC('ABC','DEF',DECROISSANT)
Affiche:
CROISSANT: -1
DECROISSANT: 1
}}
'''REP'''() Répeter la chaîne<br /><chaîne> ← REP( <chaîne>,<nombre> )<br />Retourne une chaîne formée de la répétition <nombre> fois de la <chaîne>. REP('X',3) --> 'XXX'
'''SCH'''() Sous-chaîne<br /><chaîne> ← SCH( <chaîne> , <nombre> , <expression> [ , <variable> ] )<br />Retourne une sous-chaîne de la <chaîne>, commençant à la position indiquée par <nombre> et finissant à la position indiquée par l'<expression>, qui peut être un nombre, ou une chaîne auquel cas la position de fin est le premier caractère de <chaîne> qui est dans l'<expression>. Si une <variable> est donnée, la position de fin lui est assignée.
'''SIN'''() Sinus<br /><nombre> ← SIN( <nombre> )<br />Retourne la valeur du sinus du nombre passé en paramètre.
'''SKP'''() Saut<br /><nombre> ← SKP( <chaîne1> , <nombre> [ , <chaîne2> ] )<br />Si <chaîne2> n'est pas donnée, alors retourne la position dans <chaîne1> de la première lettre après la position indiquée, sinon retourne la position dans <chaîne1> du premier caractère qui n'est pas dans <chaîne2>.
'''TAN'''() Transforme en majuscule<br /><nombre> ← TAN( <nombre> )<br />Retourne la valeur de la tangente du nombre passé en paramètre.
'''TEM'''() Temps<br /><nombre> ← TEM()<br />Retourne le nombre de secondes écoulées depuis le début du jour (00:00:00).
'''TMA'''() Transforme en majuscule<br /><chaîne> ← TMA( <chaîne> )<br />Transforme la chaîne en majuscule. Retourne une nouvelle chaîne ou change celle passée ?
'''TMI'''() Transforme en minuscule<br /><chaîne> ← TMI( <chaîne> )<br />Transforme la chaîne en majuscule. Retourne une nouvelle chaîne ou change celle passée ?
== Préprocesseur ==
LSE2000 intègre un préprocesseur.
=== Déclaration de symboles ===
Déclaration de symbole de préprocesseur.
#DECLARER ''MONSYMBOLE''
=== Generation de messages ===
==== Messages d'erreurs ====
Provoque une erreur à la compilation.
#ERREUR ''MONERREUR''
==== Messages d'avertissement ====
Provoque un avertissement à la compilation.
#AVERTISSEMENT ''MONAVERTISSEMENT''
==== Messages d'information ====
Affiche une information à la compilation.
#MESSAGE ''MONMESSAGE''
== Exemples de programme ==
Un simple programme de tour d'Hanoi en LSE (LSE2000).
(*
** TOURS D'HANOI
*)
PROCEDURE &HANOI(ENTIER N, ENTIER SRC, ENTIER DST, ENTIER TMP) LOCAL N,SRC,DST,TMP
SI N=0 ALORS RETOUR FINSI
&HANOI(N-1,SRC,TMP,DST)
AFFICHER['PORTER ',U,' SUR ',U,/] SRC,DST
&HANOI(N-1,TMP,DST,SRC)
FIN
AFFICHER[U,/] 'TOURS D''HANOI'
ENTIER Nbr
AFFICHER[U,X] 'Nombre de disque:';LIRE Nbr
&HANOI(Nbr,0,1,2)
AFFICHER[U,/] 'fin'
Un simple programme de menu en LSE (LSE2000)
(*
** UN SIMPLE MENU A L'ANCIENNE
** UTILISANT L'INSTRUCTION SELON-ALLER EN-SINON EN
*)
ENTIER X
0 AFFICHER [U,/] 'Entrez le numero de l''item voulu'
AFFICHER [U,/] '0 Choix 0'
AFFICHER [U,/] '1 Choix 1'
AFFICHER [U,/] '2 Choix 2'
LIRE X
SELON X ALLER EN 1,2,3 SINON EN 0
1 AFFICHER [U,/] 'Vous avez choisi 0!'
TERMINER
2 AFFICHER [U,/] 'Vous avez choisi 1!'
TERMINER
3 AFFICHER [U,/] 'Vous avez choisi 2!'
== Liens externes ==
Site Officiel de LSE2000
[[Catégorie:Langages de programmation|LSE]]
ryocom64glmlhipu5ndki1daizlhioh
Fonctionnement d'un ordinateur/L'émission des instructions
0
65843
774981
774757
2026-10-11T22:57:39Z
Mewtow
31375
Suppression de contenu déplacé dans d'autre chapitre, pour préparer à une réutilisation du chapitre
774981
wikitext
text/x-wiki
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
1uu9qktmvr8n28ewh8auhzfhpz7sx3g
774982
774981
2026-10-11T22:58:15Z
Mewtow
31375
774982
wikitext
text/x-wiki
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
pzj2n8mzcxzwg4mk3m7gg6x2c15xfxe
774983
774982
2026-10-11T22:58:35Z
Mewtow
31375
774983
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
2kl5qrmehurx4crjqunkerrb836sc0a
774984
774983
2026-10-11T22:59:42Z
Mewtow
31375
774984
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
r105jolqd44gtsap1xtkgy2narlpeq4
774990
774984
2026-10-11T23:06:02Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions]] vers [[Fonctionnement d'un ordinateur/L'émission des instructions]]
774984
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
r105jolqd44gtsap1xtkgy2narlpeq4
774996
774990
2026-10-11T23:07:18Z
Mewtow
31375
/* La fréquence des avals des pipelines multicycle */
774996
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines dynamiques
| prevText=Les pipelines dynamiques
| next=Les premiers processeurs Intel
| nextText=Les premiers processeurs Intel
}}
</noinclude>
gq3pagivsyp3ex3cpgt2s1qgdk2d0sh
774997
774996
2026-10-11T23:07:50Z
Mewtow
31375
/* Les unités de calcul à double fréquence du Pentium 4 */
774997
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
slex4gg7a6wnyfff68t05lbdo1frnck
775001
774997
2026-10-11T23:11:03Z
Mewtow
31375
/* Les unités de calcul à double fréquence du Pentium 4 */
775001
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
lmyztrwlwynldhvy8bv27vmaeldpv61
775008
775001
2026-10-11T23:17:35Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
775008
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Ici, nous allons nous intéresser du cas où une instructions veut utiliser un aval déjà occupé par une instruction multicycle.
Une micro-opération est émise dans la voie adaptée, si cette voie est libre. Mais l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
dblmeiq4bb5gx5ypumw29ejzkoqw246
Fonctionnement d'un ordinateur/L'exécution dans le désordre
0
65869
775005
774799
2026-10-11T23:12:52Z
Mewtow
31375
/* Le replay pipeline du Pentium 4 */
775005
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Le nombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Plus haut, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le scoreboard et le ROB
| prevText=Le scoreboard et le ROB
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
e1txgitgs9777yrvsphqnempd88x8gz
775009
775005
2026-10-11T23:19:25Z
Mewtow
31375
/* L'émission anticipée des accès mémoire */
775009
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Le nombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU.
Si une instruction réutilise le résultat d'une autre, cela peut poser problème. Imaginons que l'on ait trois cycles entre la file d'attente et les ALU. Si une instruction est émise quand l'opérande sorte de l'ALU, elle prendrait plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Et pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''.
Le nombre de cycles d'avance dépend de l'opération, précisément du temps d'exécution de l'opération dans l'ALU. Le cas le plus simple est celui d'une opération qui s’exécute en un seul cycle d'horloge, comme une addition ou une soustraction. Le nombre de cycles d'avance est alors le nombre d'étages entre l'unité de calcul et la fenêtre d'instruction. Pour une instruction multicycle, il faut ajouter le nombre de cycles que l'opération passe dans l'ALU (moins un cycle). La conséquence est que cela ne marche que pour des instructions qui ont une durée fixe, connue à l'avance. Et autant c'est le cas pour les instructions arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire.
===L'émission anticipée des accès mémoire===
Il y a quelques chapitres, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le scoreboard et le ROB
| prevText=Le scoreboard et le ROB
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
6uvvc414wm95918y6dgroqubjx2w574
775011
775009
2026-10-11T23:32:26Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
775011
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions.
[[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]]
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique. Les modifications les plus importantes sont l'intégration de plusieurs ''back-end'' sur les pipelines dynamiques, l'ajout de techniques de prédiction de branchement, le support des exceptions précises avec un ROB, l'ajout de l'exécution dans le désordre. Le résultat est un processeur tel qu'illustré ci-dessous.
Vous voyez que celui-ci est organisé en quatre "sur-circuits", quatre regroupements de circuits aux fonctions bien distinctes.
* Le '''''front-end''''' s'occupe du chargement des instructions et de leur décodage. Il regroupe l'unité de chargement, les décodeurs et l'unité de prédiction de branchement, parfois une unité de préchargement d'instructions.
* Le ''back-end'' ou '''chemin de données''' correspond au chemin de données, avec les unités de calcul, les registres, mais aussi le réseau de contournement.
* L''''unité d'exécution dans le désordre''' regroupe le ROB, le ou les ''schedulers'', et d'autres circuits qu'on n'a pas encore vu à ce stade du cours (lunité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, le contrôleur mémoire, ainsi que d'autres structures.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium 4 Block Diagram - 4332479141.jpg|centre|vignette|upright=2.5|Processeur avec exécution dans le désordre.]]
===Le nombre d'instructions en vol augmente===
Maintenant, parlons un peu des ''instructions en vol''. Pour rappel, une instruction en vol est une instruction en cours de traitement dans le pipeline. Sur les processeurs ''in-oder'', il s'agit des µops émises, qui sont soit en train de lire leurs registres, de s'exécuter dans une ALU/FPU/unité mémoire, ou qui attendent de pouvoir enregistrer leurs résultats dans les registres. Sur un processeur OOO, il faut aussi rajouter les µops en attente dans le ''scheduler''. La conséquence est qu'un processeur OOO peut traiter plus d'instructions en vol qu'un processeur ''in-order''. Il est donc plus performant. Mais cela a aussi d'autres conséquences.
Une autre conséquence, liée à la précédente, est que la prédiction de branchement devient plus importante. Un processeur ''in-order'' a une pénalité de prédiction assez faible, car il a peu d'instructions en vol. A l'opposé, un processeur OOO a tellement d'instructions en vol que n'en annuler ne serait-ce que la moitié à cause d'une prédiction de branchement demande d'annuler une bonne centaine d'instructions. Les processeurs OOO ont donc des unités de prédiction de branchement bien plus pêchues que leur équivalent ''in order''.
===La présence quasiment systèmatique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache non-bloquants===
Un énorme avantage de l'exécution dans le désordre est que les défauts de cache ne bloquent plus le pipeline. Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Avec un pipeline ''in-order'', le processeur n'a pas le choix et doit bloquer le pipeline, pour plusieurs raisons. La première est qu'il ne faut pas que des instructions exécutées après la lecture enregistrent leurs résultats dans les registres. A la rigueur, ce problème peut être résolu avec un ROB. Le second problème est les dépendances entre la lecture et d'autres instructions.
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures. Appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Les instructions lecture-dépendantes doivent être retardées tant que le résultat de la lecture n'est pas connu. Avec l'exécution dans le désordre, retarder les instructions lecture-dépendantes se fait naturellement. Le processeur ne démarre celles-ci que quand l'opérande est disponible, donc quand la lecture s'est terminée. L'unité mémoire prévient naturellement quand la donnée lue est disponible, via un signal ''Data Available''.
Sur un processeur ''in-order'', la situation est beaucoup plus compliquée. Une implémentation simple bloquerait le pipeline tant que le résultat d'une lecture n'est pas disponible. Mais ce n'est pas du tout ce qui est fait en pratique. A la place, le processeur part du principe que la lecture ne fera pas de défaut de cache. Les instructions sont donc émises en tenant compte de la latence normale du cache L1, en supposant que les lectures seront les plus rapides possibles. En cas de défaut de cache, le pipeline est intégralement gelé.
===Les accès mémoire avec l'exécution dans le désordre===
L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire.
[[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]]
Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse.
Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée.
[[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]]
La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''.
De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''.
[[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]]
Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela.
Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le scoreboard et le ROB
| prevText=Le scoreboard et le ROB
| next=Les schedulers et stations de réservation
| nextText=Les schedulers et stations de réservation
}}
</noinclude>
0cxoqpy7gv6r5vcoa18vggd661p3hgq
Fonctionnement d'un ordinateur/Le scoreboard et le ROB
0
65886
774829
774808
2026-10-11T14:57:25Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */ Déplacement chap pipelines fixes
774829
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
La présence de plusieurs voies a elle seule ne permet pas d'avoir un pipeline dynamique. La vraie différence est comment l'unité d'émission alimente ces voies. Un pipeline ''in-order'' ne permet pas d'utiliser plusieurs voies en même temps. Quand une voie est utilisée pendant plusieurs cycles, le pipeline est bloqué. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ce qu'il y a dans les autres voies. Tnat qu'une unité est libre, l'unité d'émission peut envoyer une micro-opération dedans.
Par exemple, prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
p78t6p0pw4i1r5l9dm571wzkz1qu4xi
774847
774829
2026-10-11T16:02:04Z
Mewtow
31375
774847
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
scnntlseh76wjsgfflape059ctfwscs
774855
774847
2026-10-11T16:42:47Z
Mewtow
31375
774855
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
ckhk5rxsheheehank5k41uqlk714rb1
774875
774855
2026-10-11T17:47:34Z
Mewtow
31375
/* Les unités de calcul à double fréquence du Pentium 4 */
774875
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines fixes
| prevText=Les pipelines fixes
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
qo4d9jjd4db8tf21fveinlimfho7neg
774928
774875
2026-10-11T19:22:56Z
Mewtow
31375
/* Les unités de calcul à double fréquence du Pentium 4 */
774928
wikitext
text/x-wiki
La quasi-totalité des processeurs, modernes comme anciens, supportent des '''instructions multi-cycle''', à savoir des instructions qui mettent plusieurs cycles à s'exécuter. Un exemple typique est celui des opérations flottantes ou des multiplications. Et gérer de telles instructions avec un pipeline pose de nombreux problèmes d'implémentation. Les instructions multi-cycles ne semblent pas vraiment coller avec un pipeline, qui a une longueur fixe, pour ce qu'on en sait pour le moment.
Pour le moment, nous avons vu des pipelines assez simples, comme le pipeline Fetch-Decode-Exec ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. Et ce, quitte à avoir des étages inutiles pour certaines instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Un pipeline fixe peut supporter les instructions multicycles, mais cela demande d'utiliser des blocages de pipeline. Une instruction multicycle occupe l'étage d’exécution pendant plusieurs cycles, les étages précédents sont gelés pendant ce temps.
Dans ce chapitre, nous allons voir comment modifier un processeur à pipeline, pour qu'il gère des instructions multi-cycles sans utiliser de blocages. La modification donne un '''pipeline dynamique'''. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. L'implémentation d'un pipeline dynamique est assez simple : il y a juste besoin d'utiliser des unités de calcul dédiées pour les instructions lentes, avec par exemple un circuit multiplieur séparé d'une ALU entière. Le temps de calcul dépend de l'ALU, et donc le nombre de cycles de l'instruction associée.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Avec un pipeline dynamique, le processeur est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''. Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut donc démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
5yxx45rtdby6bousrkhhw9tclv2z9vx
774949
774928
2026-10-11T21:34:32Z
Mewtow
31375
774949
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeurs à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multicycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
bhedls6eclg5bezdzzfmbr5irmj1964
774950
774949
2026-10-11T21:40:49Z
Mewtow
31375
774950
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
lmqj1couv3gf254zz6wxucrljhkplho
774951
774950
2026-10-11T21:50:41Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774951
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données. Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
La présence d'un ROB permet d'implémenter de nombreuses optimisations, qu'un processeur sans ne peut pas se permettre. Et il est intéressant d'étudier ces optimisations. Mais avant tout chose, précisons que ces optimisations ne sont pas spécifiques aux ROBs. Elles sont compatibles avec un tampon d'historique, un banc de registre futurs, etc. En clair : tout ce qui permet de gérer des exceptions précises, ''sauf les registres d’échelonnement''. Les optimisations qu'on va voir demandent que les écritures dans les registres se fassent dans le désordre, ce que ne permettent pas les registres d’échelonnement. Mais nous dirons "avec un ROB" par abus de langage, faute de terme plus adapté.
Avec un pipeline fixe, les instructions sont émises dans l'ordre du programme, s'exécutent et terminent dans l'ordre du programme, et enregistrent leurs résultats dans les registres dans ce même ordre. De tels processeurs sont appelés des '''processeurs ''in-order'''''. Ils n'ont pas de ROB, mais juste des registres d’échelonnement. Pour forcer les écritures à se faire dans l'ordre, ils utilisent des registres d’échelonnement, une file d'écriture pour les écritures en mémoire RAM, et des blocages de pipeline si besoin.
Avec un ROB, les micro-opérations sont émises dans l'ordre, mais elles peuvent se terminer dans le désordre. Les écritures dans les registres sont retardés par le ROB, mais l'exécution de l'instruction ne l'est pas. Avec un ROB, les blocages de pipeline se font donc plus rares, il faut vraiment une dépendance de type RAW, ou une dépendance structurelle, pour bloquer le pipeline.
Un processeur avec un ROB est dit de type '''''in-order issue - out-of-order commit'''''. Le terme ''commit'' désigne le moment où une instruction fournit son résultat. Ce n'est pas le moment où ce résultat est enregistré dans les registres, mais celui où il est disponible via le réseau de contournement. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact. Pour simplifier, je parlerais de processeurs ''in-order'' avec un ROB, histoire de faire simple.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
aynkztlvzv0ndxv3atglxyc8am2z5up
774952
774951
2026-10-11T21:51:20Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774952
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données. Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
==Les processeurs ''in-order issue'', ''out-of-order commit''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
ayaw7e218zri7c8vgcvmyebpmfhac9b
774953
774952
2026-10-11T21:51:39Z
Mewtow
31375
/* Les processeurs in-order issue, out-of-order commit */
774953
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données. Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
s09u471wqxk05u7kzj9rpgo9euja0c7
774954
774953
2026-10-11T21:59:32Z
Mewtow
31375
/* Le scoreboard : une unité d'émission améliorée */
774954
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données. Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
Pour l’expliquer de manière générale, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
czvpso35qmeabge2u1db94aopcxmh3p
774955
774954
2026-10-11T22:00:35Z
Mewtow
31375
774955
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
===Les dépendances structurelles liées aux unités de calcul===
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances de données===
Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
Pour l’expliquer de manière générale, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
l897bn79zotid1ola33m4a1zkjslhk1
774957
774955
2026-10-11T22:04:50Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */ Déplacement autre chapitre
774957
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances de données===
Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
Pour l’expliquer de manière générale, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
1ntm3bgqszxinuj08dp3pb89q84sa59
774960
774957
2026-10-11T22:05:49Z
Mewtow
31375
/* La gestion de la disponibilité des ALUs */
774960
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances de données===
Pour détecter les dépendances RAW, le processeur intègre un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
Pour l’expliquer de manière générale, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
pr784scst83lyw2zhac46ryxpmo63rl
774963
774960
2026-10-11T22:11:40Z
Mewtow
31375
/* Le scoreboard : une unité d'émission améliorée */
774963
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===L détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
0vgo1zkfc8mye9xvn9kvc1y5wdohfgb
774964
774963
2026-10-11T22:11:46Z
Mewtow
31375
/* L détection des dépendances WAW et RAW */
774964
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
7vkos47hpmft141bzbzhz0op4mmwpdy
774965
774964
2026-10-11T22:13:31Z
Mewtow
31375
/* La détection des dépendances WAW et RAW */
774965
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation''''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en question sont dit consulté, les autres sont dit non-consultés.
Il est interdit d'écrire dans un registre consulté, c'est signe d'une dépendance WAR. Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
av565eiuy7hpppphx0h6rfkpqvyef13
774966
774965
2026-10-11T22:14:13Z
Mewtow
31375
/* La détection des dépendances WAW et RAW */
774966
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==Les optimisations/avantages d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
47ul9f8ru532acsb0hf5yv12i0k6sla
774967
774966
2026-10-11T22:18:32Z
Mewtow
31375
/* Les optimisations/avantages d'un processeur avec un scoreboard */
774967
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
Un problème des pipelines dynamiques est qu'ils ne garantissent pas l'ordre des écritures, dans les registres. Si on émet des instructions dans l'ordre du programme, il se peut qu'elles ne terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
4bgs6i8vzlhs57zacfr692umgkotqt1
774968
774967
2026-10-11T22:19:38Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774968
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un pipeline fixe ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les pipelines fixes, avec le ''result shift register'', je ne reviens pas dessus. Les pipelines dynamiques utilisent quant à eux une seconde solution : '''retarder les écritures dans les registres''', jusqu'à ce qu'elles atteignent la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées. Une solution, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais avec elle, le pipeline obtenu n'est pas un pipeline dynamique. heureusement, d'autres solutions sont possibles.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
co5n3b0509ldekq3fg6awn4qm44t92g
774969
774968
2026-10-11T22:22:13Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774969
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, peu importe l'ordre des écritures, tout se passera bien. Mais si les deux écritures se font dans le même registre, il y a un problème : le résultat est incorrect, un registre a été écrasé avec une valeur antérieure. Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendances WAW''.
Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, vu qu'ils écrivent dans le même registre. Le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW imposent un ordre concernant les écritures dans les registres et posent des problèmes sur les pipelines dynamiques, uniquement eux.
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
4gxmbunedfivtep4pqsc8qu9vkb2du3
774970
774969
2026-10-11T22:25:01Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774970
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent. Un ''scoreboard'' fait donc la détection des dépendances en général, qu'elles soient structurelles ou de données.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Mais étudions comment elles sont détectées. Cette fois, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. ces registres en attente de lecture sont comparés avec le registre de destination de l'instruction candidate. Si il y a correspondance, une dépendance WAR est possible.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il faut noter que beaucoup de pipeline s'en passent car ils n'ont pas de dépendances WAR. Le registre de consultation est mis à jour dès que l'opérande est lue. Et cela arrive assez tôt dans le pipeline, au bout de quelques cycles grand maximum. Sur beaucoup de pipelines, il n'est pas présent dans il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
79k2docmii52jcaak84303ipr9lppv7
774971
774970
2026-10-11T22:27:37Z
Mewtow
31375
/* Le scoreboard : une unité d'émission améliorée */
774971
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW et RAW===
Pour les dépendances WAW, le ''scoreboard'' regarde uniquement les registres de destination. Il regarde celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Sur beaucoup de pipelines, il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte. Pour les détecter, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. Ces registres en attente de lecture sont utilisés pour voir si une instruction écrira dedans, quelques cycles après son émission.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
mslpaf85bmdg1en366vac3obie8wkx0
774972
774971
2026-10-11T22:29:40Z
Mewtow
31375
/* La détection des dépendances WAW et RAW */
774972
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
===La détection des dépendances RAW===
Pour expliquer la détection des dépendances, un petit rappel. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Les micro-opérations qui sont dans le pipeline, dans les étages après l'unité d'émission, sont appelées '''micro-opérations "en vol"'''. La détection des dépendances demande de comparer les registres de la micro-opération candidate avec ceux des micro-opérations en vol.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Et il faut distinguer les registres lus de ceux écrits. Les registres lus sont ceux qui contiennent les opérandes de l'instruction. Ils sont donc appelés les ''registres opérandes''. Le registre dans lequel le résultat est écrit est appelé le '''registre de destination'''.
Pour détecter les dépendances RAW, le ''scoreboard'' fait la même chose que l'unité d'émission du chapitre précédent. Elle compare les registres opérandes de la micro-opération candidate, avec les registres destination des micro-opérations en vol. En clair, elle regarde quels registres la micro-opération candidate lit, pour voir s'il y a une écriture en attente pour ce registre.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Le processeur mémorise les registres réservés dans un registre de disponibilité, qui permet de réserver un registre en écriture. Une instruction émise réserve un registre en écriture, et toute lecture dans ce registre se traduit par une dépendance RAW. Le mécanisme peut être adapté pour détecter les dépendances WAW et WAR.
===La détection des dépendances WAW===
Pour les dépendances WAW, le ''scoreboard'' regarde les registres de destination des micro-opérations/instructions. Il compare celui de la micro-opération candidate avec ceux des micro-opérations en vol. S'il y a une correspondance, ca veut dire que la micro-opération candidate veut écrire dans le même registre qu'une micro-opération en vol. Il y a donc dépendance WAW. La micro-opération n'est pas émise tant que l'instruction dépendante n'est pas terminée. Le registre de disponibilité peut être réutilisé tel quel, mais le tout peut être optimisé, histoire d'émettre la micro-opération candidate plus tôt.
Une solution plus optimale réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre peut réserver un registre pendant N cycles. Le registre est alors inaccessible ené criture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW.
===La détection des dépendances RAW===
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Sur beaucoup de pipelines, il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte. Pour les détecter, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. Ces registres en attente de lecture sont utilisés pour voir si une instruction écrira dedans, quelques cycles après son émission.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
1f5z0inr1jjjl9pmmxefnfowkcum7bq
774973
774972
2026-10-11T22:30:38Z
Mewtow
31375
/* Le scoreboard : une unité d'émission améliorée */
774973
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Le ''scoreboard'' : une unité d'émission améliorée==
Le '''scoreboard'' est une unité d'émission un peu différente de celle du chapitre précédent. Pour rappel, l'unité d'émission d'un processeur ''in-order'' a trois fonctions : détecter les dépendances structurelles, détecter les dépendances de type RAW, et remettre en ordre les écritures. La dernière fonction est prise en charge par un ''result shift register'', mais elle est inutile pour un ''scoreboard''. Par contre, les dépendances WAW et WAR, qui étaient annulées par la remise en ordre des écritures, apparaissent. Le ''scoreboard'' n'a plus à remettre en ordre les écritures, mais doit détecter les dépendances d'écriture qui en découlent.
===La détection des dépendances WAW===
Pour les dépendances WAW, le ''scoreboard'' réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre peut réserver un registre pendant N cycles. Le registre est alors inaccessible ené criture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW.
===La détection des dépendances RAW===
Pour les dépendances WAR, les choses sont plus compliquées. Elles n'apparaissent que sur certains pipelines, dans des circonstances spécifiques. Sur beaucoup de pipelines, il est impossible qu'une écriture ait lieu avant qu'une lecture ait démarrée. Mais sur les pipelines où de telles dépendances sont possibles, il faut en tenir compte. Pour les détecter, il faut regarder quels sont les registres lus par les instructions en vol, les lectures pas encore réalisées. Ces registres en attente de lecture sont utilisés pour voir si une instruction écrira dedans, quelques cycles après son émission.
Pour savoir quels sont les registres en attente de lecture, le processeur utilise un équivalent du registre de disponibilité, mais pour les lectures (en attente) : le '''registre de consultation'''. Il permet de désigner les registres qui vont être lus par les instructions en vol. Je précise bien : les registres qui n'ont pas encore été lus. Les registres en attente de lecture sont dit non-consultés, les autres sont dit consultés. Il est interdit d'écrire dans un registre non-consulté, c'est signe d'une dépendance WAR.
Il existe quelques rares processeurs qui fusionnent le registre de réservation et de consultation en un seul. Le registre contient autant de bits que de registres, chaque bit indique si le registre associé est pas encore lu/écrit. De tels implémentations détectent alors toutes les dépendances, même les dépendances Read After Read. Elles tendent à être très conservatrices. Un exemple est le co-processeur vectoriel VFP9-S d'ARM. Leur implémentation est simple, mais il peut y avoir un cout en performance.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
hsgpmaqom2tefi1m8xcrehkfg137jod
774974
774973
2026-10-11T22:39:19Z
Mewtow
31375
/* Le scoreboard : une unité d'émission améliorée */
774974
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui détecte les dépendances de données u sens général, à savoir les trois dépendances RAW, WAW et WAR.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
hdbje09dcfdl604djh057hoaygm7sij
774975
774974
2026-10-11T22:41:02Z
Mewtow
31375
774975
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
Par contre, ces techniques ne résolvent pas tout. Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise..
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
kjfsz43tx92qyzpclucnhug9ego2d1t
774976
774975
2026-10-11T22:41:35Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
774976
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
r95jc98kv2hkmui5ublti269mu34kur
774977
774976
2026-10-11T22:47:01Z
Mewtow
31375
/* La fréquence des avals des pipelines multicycle */
774977
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs ''in-order'' avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline, du moins pour les lectures. Le processeur peut donc continuer à exécuter des instructions, pendant que le défaut de cache est géré par l'unité mémoire. Et la présence d'un ROB est absolument nécessaire pour cette optimisation.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
m0zerdbqivrgbn5k4g1mbvdttogco5n
774978
774977
2026-10-11T22:54:21Z
Mewtow
31375
/* La microarchitecture d'un processeur avec un scoreboard */
774978
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquantes'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
gx82eagc9x217yang8ieujc9xnpm0tt
774979
774978
2026-10-11T22:54:57Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
774979
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquantes'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquantes'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
2om6kjatw7fporbqwdmmrjwgxr8rvwl
774980
774979
2026-10-11T22:55:13Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
774980
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
glxxn9o21qgaqvjf6xms52nq6ghru3t
774986
774980
2026-10-11T23:00:46Z
Mewtow
31375
/* La fréquence des avals des pipelines multicycle */ Déplacement dans un autre chapitre
774986
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
oycmff0i2y5o4rurbql0vankrj2572u
775002
774986
2026-10-11T23:12:16Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
774986
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
oycmff0i2y5o4rurbql0vankrj2572u
775010
775002
2026-10-11T23:32:25Z
Mewtow
31375
/* Le scoreboard sans exceptions précises */
775010
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et la fenêtre d'instruction.
===L'émission anticipée des accès mémoire===
Il y a quelques chapitres, nous avons vu comment appliquer le contournement à l'unité mémoire. Mais cela ne marche que dans un cas simple, où une lecture fait un cycle d'horloge. Dans les processeurs modernes, les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée et le contournement en général.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des instructions en supposant que la lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache. Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
ssulub1c6x898oari4ogp64mbmsp0l1
775012
775010
2026-10-11T23:35:07Z
Mewtow
31375
/* L'émission anticipée des accès mémoire */
775012
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et la fenêtre d'instruction.
===L'émission anticipée des accès mémoire===
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
1i5907xjaa6fz2xa13noiv5g4wy2j74
775013
775012
2026-10-11T23:36:58Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
775013
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consomatrice'''. SIl arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et la fenêtre d'instruction.
===L'émission anticipée des accès mémoire===
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont alors émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation. Et pour corriger la situation, il y a plusieurs solutions.
Le pipeline RISC classique gèle totalement le processeur en cas de défaut de cache. Les instructions restaient à leur place, elles ne bougeaient pas, et le processeur reprenait là où il en était une fois le défaut de cache terminé. Mais cette solution, bien qu'idéale, n'est pas la plus pratique. Une solution alternative ne gèle que l'instruction dépendante sur place, dans le pipeline. L'instruction dépendante attend en entrée de l'unité de calcul que l'opérande nécessaire soit disponible, que le défaut de cache se passe. Le problème est que tout ce qui précède l'unité de calcul doit aussi être gelé : l'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Le défaut de cette solution est qu'elle est difficile à mettre en œuvre, sauf à geler totalement le processeur comme le faisaient les processeurs avec un pipeline RISC classique, comme dit plus haut.
Une autre solution annule toutes les instructions après celles émise à tort, avec les circuits pour les exceptions précises. Un défaut de cache déclenche une exception matérielle interne au processeur. Le processeur élimine alors les toutes instructions émises après le défaut de cache, dont les instructions lecture-dépendantes mais pas que. Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
ejdqh03g3w9p1v6af8mkqmfpkybftbf
775014
775013
2026-10-11T23:42:23Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
775014
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''. Il arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
===L'émission anticipée des accès mémoire===
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Une autre solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
eo4pzhtlb3tf6mu416u0nftq2g7hc8b
775015
775014
2026-10-11T23:43:02Z
Mewtow
31375
/* L'émission anticipée des accès mémoire */
775015
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''. Il arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
===L'émission anticipée des accès mémoire===
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
2ihhqxo7emp9lazqcpiqok1yf3a12le
775019
775015
2026-10-11T23:48:17Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
775019
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Les défauts de cache non-bloquants===
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire.
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''. Il arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
g0nrelsy2gbx19v58j9ewt2tp4dk971
775021
775019
2026-10-11T23:50:55Z
Mewtow
31375
/* La microarchitecture d'un processeur avec un scoreboard */
775021
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
==L'émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle instruction sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Si une instruction réutilise le résultat d'une autre, cela peut poser problème.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''. Il arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
r9p06nvytsho1hvyrtuwq9h1nl8m9v2
775022
775021
2026-10-11T23:51:02Z
Mewtow
31375
/* L'émission anticipée des micro-opérations */
775022
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions multi-cycle écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécute, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
6w4hd5okor28rvrww17t31x06skv107
775023
775022
2026-10-11T23:52:16Z
Mewtow
31375
/* Le commit multiple dans le ROB */
775023
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ''scoreboard''==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
hn5ce7n142giyd6e4oqg1u6wxt8cio2
775024
775023
2026-10-11T23:52:32Z
Mewtow
31375
/* La microarchitecture d'un processeur avec un scoreboard */
775024
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ROB==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===Le ''scoreboard'' sans exceptions précises===
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
7yyuc9dzkthkvneeffn1dyvyngnx8vq
775025
775024
2026-10-11T23:52:43Z
Mewtow
31375
/* Le scoreboard sans exceptions précises */
775025
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ROB==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
ta7djimnaf2zg9snxdchyhrt2sfm9yy
775026
775025
2026-10-11T23:52:56Z
Mewtow
31375
/* Le replay pipeline du Pentium 4 */
775026
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==La microarchitecture d'un processeur avec un ROB==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
==Annexe : le ''scoreboard'' sans exceptions précises==
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
jy94lawkdjdw7kk6tp5atvsd9epc2nl
775027
775026
2026-10-11T23:53:59Z
Mewtow
31375
/* La complétion dans le désordre */
775027
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La microarchitecture d'un processeur avec un ROB==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
==Annexe : le ''scoreboard'' sans exceptions précises==
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
puvjzp8opkpa0hqril5kvb6aus618bm
775028
775027
2026-10-11T23:54:07Z
Mewtow
31375
/* Le commit multiple dans le ROB */
775028
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La microarchitecture d'un processeur avec un ROB==
Un processeur avec un ''scoreboard'' exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
==Annexe : le ''scoreboard'' sans exceptions précises==
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
nvfdpt5mjdy9n9znb3kys6w4zccma1a
775029
775028
2026-10-11T23:56:12Z
Mewtow
31375
/* La microarchitecture d'un processeur avec un ROB */
775029
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, râce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
==Annexe : le ''scoreboard'' sans exceptions précises==
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
en0mg0zkenlpb3ir2vu6lgdgq1hxvlm
775030
775029
2026-10-11T23:56:53Z
Mewtow
31375
/* Le contournement avec un ROB */
775030
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order'' classiques, avec un pipeline dynamique. Ils n'ont pas de registres d'échelonnement, ils font avec un pipeline dynamique. La différence est que la remise en ordre des écritures se fait à la fin du pipeline, pas au début. Avec un processeur ''in-order'', l'unité d'émission retarde les instructions, pour éviter une inversion de l'ordre des écritures. Mais un processeur à ''scoreboard'' ne le fait pas. Il laisse les instructions calculer leurs résultats dans le désordre. Par contre, ces résultats ne sont pas écrits dans les registres immédiatement. A la place, ils sont mis en attente dans une structure quelconque, qui fait les écritures dans l'ordre.
[[File:Processeur avec un scoreboard.png|centre|vignette|upright=2.5|Processeur avec un scoreboard]]
L'unité d'émission est appelée un '''''scoreboard''''', avec cependant un petit détail terminologique. Le terme ''scoreboard'' est utilisé pour désigner beaucoup de choses. Il a notamment été utilisé pour désigner le ''scoreboard'' du CDC 6600, mais c'était une implémentation très particulière, qui ne correspond pas du tout aux ''scoreboard'' vus dans ce chapitre. Le CDC 6600 ne gérait même pas les exceptions précises. Mais nous verrons cela dans le chapitre dédié.
Pour ce chapitre, un ''scoreboard'' est une unité d'émission, mais qui est légèrement simplifiée comparé à celle du chapitre précédent. L'unité du chapitre précédent a trois fonctions : détecter les dépendances structurelles, détecter les dépendances RAW, remettre en ordre les écritures. Un ''scoreboard'' se passe de la troisième fonction.
==L'inversion de l'ordre des écritures==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
Les techniques précédentes remettent dans l'ordre les écritures dans les registres, afin de gérer les branchements et exceptions. Mais elles s'appliquent sur toutes les instructions, même en absence de branchements. Mais diverses optimisations permettent de contourner ces techniques ou de les désactiver dans des conditions bien précises, pour gagner en performance, tout en garantissant que cela n'ait pas de conséquences sur l'exécution du programme. Il s'agit de techniques dites de '''complétion dans le désordre'''.
En général, elles s'appliquent en absence de branchements ou quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les écritures se font dans le désordre. Les écritures sont faites en avance, alors que des instructions précédentes ne sont pas terminées. Par valider des écritures en avance, on veut parler de mettre à jour les bancs de registre, qu'il s'agisse du ''retirement register file'', du point de contrôle, ou toute autre structure des techniques précédentes. Rappelons que le ''scoreboard'' ou l'unité d'émission s'arrange pour l'exécution dans le désordre ne change pas le comportement du programme exécuté.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme qui s’applique en absence de branchements. Le mécanisme s'active quand une lecture déclenche un défaut de cache ou toute autre situation. En théorie, le processeur devrait bloquer son pipeline, mais le processeur ne le fait pas. A la place, il continue à exécuter des instructions malgré le défaut de cache, à condition que les instructions ne dépendent pas de la lecture. L'idée est alors de laisser ces instructions écrire dans le banc de registre, alors que la lecture précédente est encore en plein défaut de cache. Il faut cependant que la lecture soit arrivée à un certain état d'avancement pour que le processeur autorise ces écritures : il faut garantir que la lecture ne déclenchera pas un défaut de page ou toute autre exception matérielle. Mais une fois que le processeur sait que la situation est OK, il autorise les instructions suivant la lecture enregistrer leurs résultats pour de bon.
Le processeur ARM Cortex 73 en question ne dispose apparemment pas de ROB, mais il doit certainement avoir une structure similaire pour garantir l'ordre des écritures quand un branchement est présent. L'avantage de la technique est qu'elle permet à certaines instructions de finir en avance, ce qui libère de la place dans le ROB, le tampon d'historique, ou toute autre structure matérielle qui met en attente les écritures. Elles permet d'avoir de meilleures performances sans augmenter la taille de ces structures, ou bien d'obtenir des performances similaires à cout en circuits réduit. Le CPU ARM Cortex 73 a un budget en transistor assez restreint, ce qui fait que cette optimisation prend tout son sens sur ce CPU.
==Les défauts de cache avec un ''scoreboard''==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elle étant les '''défauts de cache non-bloquants'''. Le terme décrit assez bien cette optimisation : les défauts de cache ne bloquent plus le pipeline. Le processeur peut continuer à exécuter des instructions dans l'ALU entière, pendant que le défaut de cache est géré par l'unité mémoire. Et ce n'est pas la seule optimisation, les processeurs avec un ''scoreboard'' peuvent implémenter de nombreuses optimisations liées aux défauts de cache, comme un ''replay system''.
===Les défauts de cache non-bloquants===
Un processeur ''in-order'' bloque le pipeline lors d'un défaut de cache, car il n'a pas le choix. Il se peut que le défaut de prenne beaucoup de temps à être traité, facilement plusieurs centaines de cycles d'horloges s'il doit lire la donnée en mémoire RAM, encore plus si le défaut de cache se double d'un défaut de page (en mémoire virtuelle). Et pendant ce temps, s'il continuait à exécuter des instructions, celles-ci sortiraient du pipeline et enregistreraient leurs résultats dans les registres. Si la lecture lève une exception, ces résultats ne peuvent pas être annulés.
Mais avec un ROB, tout change. Le processeur peut continuer à exécuter des instructions après un défaut de cache. Si le processeur a un ROB suffisamment grand, il peut continuer à exécuter des instructions après un défaut de cache. Mais les instructions en question ne quitteront pas le pipeline. Elles seront mises en attente dans le ROB. L'enregistrement du résultats dans les registres est retardé tant que le défaut de cache n'est pas résolu. Pour compenser, les résultats en attente dans le ROB sont accessibles par les autres instructions via le réseau de contournement.
Un autre problème est la gestion des instructions dépendantes de la lecture. Et c'est une autre raison qui force les processeurs ''in-order'' à bloquer leur pipeline en cas de défaut de cache. Imaginez qu'une instruction utilise une donnée lue par une lecture précédente. Si on ne bloquait pas le pipeline, l'instruction se propagerait dans le pipeline normalement, jusqu'à lire le registre de destination de la lecture. Mais si la donnée n'est pas encore arrivée, à cause du défaut de cache, la donnée lue sera invalide. Si une instruction en vol a une dépendance avec la lecture, pas le choix : il faut geler le pipeline. Et ce avec ou sans ROB.
La présence d'un ROB n'est pas absolument nécessaire pour cette optimisation. Nous avions vu dans le chapitre précédent que les processeurs ARM Cortex A53, A55 et A510, intégraient de tels ''défauts de cache non-bloquants'', mais seulement pour les lectures. Mais sans ROB, ils ne pouvaient exécuter en avance qu'une dizaine d'instruction, guère plus. Il y avait maximum une instruction par étage de pipeline, la lecture était en quelque sorte bloquée dans l'étage de ''writeback'' et les instructions s'accumulaient dans les étages précédent.
Mais avec un ROB, on peut aller plus loin, et mettre en attente une bonne centaine d'instructions. Sachant qu'un défaut de cache L1 peut prendre entre 10 et 100 cycles d'horloge, avec des pointes à plusieurs milliers en cas de lecture RAM ou de défaut de page, ce n'est pas du luxe. Ainsi, un processeur avec ROB a un avantage certain, ce qui fait que tous les processeurs modernes en utilisent un.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ''scoreboard'' ne se bloquent pas lors d'un défaut de cache, mais ils peuvent en plus en profiter pour gérer le contournement de l'unité mémoire. Pour rappel, l'idée est qu'une instruction peut lire en avance une donnée lue. La lecture est disponible en sortie de l'unité mémoire, avant d'être écrite dans les registres. Elle est disponible via le réseau de contournement.
Pour profiter du contournement, on doit émettre une instruction en avance, pour qu'elle arrive en entrée de l'ALU pile quand l’opérande est disponible. L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Et cela pose des problèmes pour l'émission anticipée et le contournement en général. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache, quitte à corriger le tout en cas de défaut de cache.
Un succès de cache prend un nombre fixe de cycles, connu à l'avance, ce qui fait qu'on peut appliquer l'émission anticipée dans ce cas. Les instructions lecture-dépendantes sont émises en tenant compte de la latence du cache L1. Pour l'exemple, disons que l'accès au cache L1 se fait en 3 cycles d'horloge : les instructions lecture-dépendantes sont alors émises de manière à arriver à l'entrée de l'ALU 3 cycles après le démarrage de la lecture. Si la lecture entraine un succès de cache, les instructions lecture-dépendantes s'exécutent normalement. Mais si un défaut de cache a lieu, les instructions lecture-dépendantes ont été émises à tord et on doit corriger la situation.
Corriger la situation peut impliquer le ROB : les instructions sont marquées comme invalides, elles n'enregistrent pas leur résultat dans les registres, et les instructions suivantes le sont aussi. Un défaut de cache déclenche une exception matérielle, qui est traitée par le système d'exception précises, comme un ROB.
Une solution alternative gèle l'instruction lecture dépendante en entrée de l'unité de calcul. La micro-opération émise en avance attente que l'opérande soit disponible, que le défaut de cache se passe. L'unité de calcul est indisponible tant que le défaut de cache n'est pas géré. Une fois le calcul terminé, elle écrira son résultat dans le ROB et le tout sera remis en ordre.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en terme de performance. Les techniques de '''prédiction de latence mémoire''' tentent de réduire les émissions anticipées fautives au maximum. Elles décident s'il faut ou non réveiller une instruction lecture-dépendante de manière anticipée. Les techniques en question sont assez complexes, mais elles essayent de prédire si une instruction va faire un défaut de cache ou non. Elles peuvent aussi tenter de prédire la durée de ce défaut de cache, mais c'est assez secondaire. Les techniques utilisées sont similaires à celles utilisées pour la prédiction de branchement : compteurs à saturation, prédiction statique, etc. Elles ne sont vraisemblablement pas utilisées dans les processeurs modernes. La raison est qu'elles demandent d'ajouter beaucoup de circuits pour des gains en performance assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tord. Elle est appelée l''''annulation sélective'''. Elle permet de conserver de bonnes performances en cas d'émission anticipée fautive. Mais son implémentation est compliquée. Concrètement, seuls les processeurs à exécution dans le désordre l'implémentent, ce qui fait qu'on verra cela dans le chapitre adéquat. Cependant, il y a une solution qui permet l'émission anticipée, mais sans exécution dans le désordre, qui sera étudiée à la fin du chapitre.
: Détail qui a son importance : un processeur ''in-order'' fait naturellement de l'émission anticipée, pour les lectures. Simplement, en cas de défaut de cache, le pipeline est totalement bloqué.
===Le ''replay pipeline'' du Pentium 4===
Le '''pipeline à répétition''' (''replay pipeline'') est une implémentation de l'émission anticipée avec annulation sélective, qui a été utilisée sur le processeur Pentium 4. L'idée derrière les pipelines à répétition est d'ajouter une sorte de boucle, en plus du pipeline mémoire normal. Les instructions se propagent à la fois dans la boucle et dans le pipeline normal. Les étages de la boucle servent à ré-exécuter les instructions en cas de problème. La boucle propage les signaux de commande de l'instruction, sans rien faire de spécial. Dans le pipeline qui exécute l'instruction, ces signaux de commande sont consommés au fur et à mesure, ce qui fait qu'à la fin du pipeline, il ne reste plus rien de l'instruction originale. D'où la présence de la boucle, qui sert à conserver les signaux de commande.
L'étage final de la boucle vérifie que l'instruction n'a pas été émise trop tôt avec un ''scoreboard'', et il regarde si l'instruction a donné lieu à un défaut de cache ou non. Si l'instruction a donné un bon résultat, une nouvelle instruction est envoyée dans le pipeline. Dans le cas contraire, l'instruction refera encore un tour dans le pipeline. Dans ce cas, l'unité de vérification va devoir envoyer un signal à l'unité d'émission pour lui dire « réserve un cycle pour l'instruction que je vais faire boucler ».
[[File:Pipeline à répétition.png|centre|vignette|upright=2|Pipeline à répétition.]]
Un point intéressant est que les micro-opérations dépendantes de la lecture sont elles aussi exécutées en avance et ré-exécutées si besoin. Prenons l'exemple d'une lecture qui lit l'opérande manquante d'une addition. Vu qu'il y a a quelques cycles entre l'émission et les unités de calcul, le processeur émet l'addition en avance de quelques cycles, pour qu'elle arrive à l'ALU en temps voulu. En théorie, l'addition ne doit être lancée en avance que si on sait avec certitude que les opérandes seront lues une fois qu'elle arrive à l'ALU. Une addition dépendante d'une lecture doit donc attendre que la lecture termine avant d'être démarrée. Mais avec le système de ''replay'', l'addition est exécutée en avance, avant qu'on sache si ses opérandes sont disponibles. Si jamais un défaut de cache a lieu, l'addition aura atteint l'ALU sans les bonnes opérandes. Elle est alors r-exécutée par le système de ''replay'' autant de fois que nécessaire.
Le principe peut s'adapter pour fonctionner avec une hiérarchie de cache. Prenons un exemple : un succès dans le cache L1 dure 3 cycles d'horloge, un succès dans le L2 dure 8 cycles, et un défaut de cache 12 cycles. Imaginons qu'une instruction fasse un défaut de cache dans le L1, et un succès de cache dans le L2. La boucle de 3 cycles utilisée pour le L1 ne permettra pas de gérer efficacement la latence de 8 cycles du L2 : l'instruction devra faire trois tours, soit 9 cycles d'attente, contre 8 idéalement. La solution consiste à retarder le second tour de boucle de quelques cycles, ajoutant une seconde boucle. La seconde boucle ajoute en théorie un retard de 5 cycles : 8 cycles, dont trois en moins pour le premier tour. Pour injecter l'instruction dans la bonne boucle, il suffit d'un multiplexeur commandé par le signal ''cache hit/miss''.
: La seconde boucle peut être raccourcie pour une lecture car les micro-opérations dépendantes de la lecture sont émises en avance.
[[File:Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.png|centre|vignette|upright=2|Pipeline à répétition avec une latence de 3 cycles pour le L1, et 8 cycles pour le L2.]]
Le même principe peut s'appliquer pour gérer les latences avec des niveaux de cache supérieurs : il faut alors utiliser plusieurs boucles de tailles différentes, en ajoutant des multiplexeurs. Il arrive que plusieurs boucles veuillent faire rentrer une instruction dans le pipeline en même temps, au niveau de l'endroit où les boucles se referment. Dans ce cas, une seule boucle peut réémettre son instruction, les autres étant mises en attente.
Divers mécanismes d'arbitrage, de choix de la boucle sélectionnée pour l'émission, sont possibles : privilégier la boucle dont l'instruction est la plus ancienne (et donc la boucle la plus longue) est la technique la plus fréquente. Mais dans certains cas, mettre une boucle en attente peut bloquer tous les étages précédents, ce qui peut bloquer l'émission de la nouvelle instruction : le processeur se retrouve définitivement bloqué. Dans ce cas, le processeur doit disposer d'un système de détection de ces blocages, ainsi que d'un moyen pour s'en sortir et revenir à la normale (en vidant le pipeline, par exemple).
[[File:Pipeline à répétition avec hiérarchie de cache multiples et exceptions.png|centre|vignette|upright=2|Pipeline à répétition pour une hiérarchie de cache.]]
Pour gérer au mieux les accès à la mémoire RAM, on remplace la boucle dédiée à la latence mémoire par une FIFO, dans laquelle les instructions sont accumulées en attendant le retour de la donnée en mémoire. Quand la donnée est disponible, lue ou écrite en mémoire, un signal est envoyé à cette mémoire, et l'instruction est envoyée directement dans le pipeline. Là encore, il faut gérer les conflits d'accès à l'émission entre les différentes boucles et la file d’attente de répétition, qui peuvent vouloir émettre une instruction en même temps.
[[File:Gestion des accès RAM sur un pipeline à répétition.png|centre|vignette|upright=2|Gestion des accès RAM sur un pipeline à répétition.]]
On peut aussi adapter le pipeline à répétition pour qu'il gère certaines exceptions : certaines exceptions sont en effet récupérables, et disparaissent si on ré-exécute l'instruction. Ces exceptions peuvent provenir d'une erreur de prédiction de dépendance entre instructions (on a émis une instruction sans savoir si ses opérandes étaient prêts), ou d'autres problèmes dans le genre. Si jamais une exception récupérable a eu lieu, l'instruction doit être ré-exécutée, et donc réémise. Elle doit refaire une boucle dans le pipeline. Seul problème : ces exceptions se détectent à des étages très différents dans le pipeline. Dans ce cas, on peut adapter le pipeline pour que chaque exception soit vérifiée et éventuellement réémise dès que possible. On doit donc ajouter plusieurs étages de vérification, ainsi que de nombreuses boucles de réémission.
==Annexe : le ''scoreboard'' sans exceptions précises==
Il est possible d'imaginer ce que donnerait un processeur sans exceptions précises. Un tel processeur simplifierait grandement le design du processeur, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, cela ne résoudrait pas tout.
Au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes. Si deux écritures se font dans des registres différents, inverser leur ordre ne concerne que le système d'exceptions précises. Mais si les deux écritures se font dans le même registre, il y a un problème : un registre a été écrasé avec une valeur antérieure, sa valeur est incorrecte.
Le problème en question correspond une '''dépendance de donnée de type ''Write After Write''''', ou ''dépendance WAW''. Le terme ''dépendance de donnée'' indique que les deux instructions ne sont pas indépendantes, le terme ''Write After Write'' indique que deux écritures doivent se faire dans un ordre précis. Sans ROB,les dépendances WAW sont gérées par le ''scoreboard'', pas par le système d'exception précise.
La détection des dépendances WAW est assez complexe. Une solution simple, mais terriblement peu pratique, compare les registres de destination, sans tenir compte des timings des instructions. L'unité d'émission compare le registre de destination des instructions en vol, avec celui de la micro-opération à émettre. S'il y a correspondance, c'est potentiellement une dépendance WAW. Je dis potentiellement, car la détection est très conservatrice, elle ne cherche pas à savoir s'il y a réelement une dépendance. Elle en tient pas en compte la durée des instructions, de quand ont lieu les écritures.
Une implémentation plus précise détecte les dépendances réelles. Elle réutilise le ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
j73a583hbhud8na2485ia31ns505z8l
Fonctionnement d'un ordinateur/Le pipeline
0
65892
774858
774688
2026-10-11T17:03:13Z
Mewtow
31375
/* Les décodeurs sont lents sur les CPU CISC */
774858
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
n5fd3cilxbp2tc54hmrmrcuo320yxc5
774859
774858
2026-10-11T17:03:35Z
Mewtow
31375
/* L'optimisation du pipeline de données */
774859
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
jashz5fj8r7we841yif335n7nezulv2
774860
774859
2026-10-11T17:04:57Z
Mewtow
31375
/* Les instructions multi-cycles et l'ALU entière */
774860
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
===L'accès aux caches L1 est lent===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
jfgozlnlbddtsz5tyvy0r4xk6d6fbhc
774861
774860
2026-10-11T17:05:11Z
Mewtow
31375
/* L'accès aux caches L1 est lent */
774861
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
nwif3ok7fxvshnw2rf34jrud3ccvnzm
774862
774861
2026-10-11T17:05:25Z
Mewtow
31375
/* Les décodeurs sont lents sur les CPU CISC */
774862
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
558ydvevvv0g69078dk742lqfp5sy4z
774863
774862
2026-10-11T17:06:39Z
Mewtow
31375
/* Les instructions multi-cycles et l'ALU entière */
774863
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes. Nous verrons comment c'est possible dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache".
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
nf2ztptha0ywcpoqabmj21mrhf3fke9
774864
774863
2026-10-11T17:08:38Z
Mewtow
31375
/* L'accès aux caches L1 est lent, mais se pipeline */
774864
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===Les instructions multi-cycles et l'ALU entière===
Le pipeline précédent a un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres. Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et cela demande de faire quelques modifications au pipeline.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
qtz4x0by6pm936udr69r1lmhfn955g8
774867
774864
2026-10-11T17:26:50Z
Mewtow
31375
/* L'optimisation du pipeline de données */
774867
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne bien un pipeline de longueur fixe. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
71tkh5o5po4l7fvhax9cxuec0nf3836
774868
774867
2026-10-11T17:27:29Z
Mewtow
31375
/* Les instructions multi-cycles et l'ALU entière */
774868
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
h02xvz372a94id9y2gawttab7keysif
774869
774868
2026-10-11T17:45:45Z
Mewtow
31375
/* Le monde réel : l'exemple des processeurs Intel */
774869
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
0qkqsa66r86npnugftt0aa63hmgl64e
774872
774869
2026-10-11T17:46:23Z
Mewtow
31375
/* Le contournement de la file d'écriture */
774872
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
iwwhya2pkeh20tlrdjcr1q16l3azf27
774992
774872
2026-10-11T23:06:12Z
Mewtow
31375
/* Le contournement de la file d'écriture */
774992
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
4egeuqmimmemzoz3y88jo6v4soadq5x
774993
774992
2026-10-11T23:06:36Z
Mewtow
31375
774993
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=L'émission des instructions
| nextText=L'émission des instructions
}}
</noinclude>
h5xwgow01i5flx86hlc7lidp89rlwj8
775000
774993
2026-10-11T23:10:44Z
Mewtow
31375
/* Le contournement de la file d'écriture */
775000
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc.
Sans pipeline, ces étapes sont réalisées les unes après les autres et une instruction doit attendre que la précédente soit terminée avant de démarrer. Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Chaque instruction passe progressivement d'une étape à la suivante dans ce pipeline et on charge une nouvelle instruction par cycle dans le premier étage.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Pour comprendre ce que cela signifie, comparons l’exécution de cette instruction sans et avec pipeline. Sans pipeline, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on démarre une nouvelle instruction par cycle (dans des conditions optimales).
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il permet d'aborder précocement les concepts de dépendances de données/structurelles, mais nous n'utiliserons pas cette approche dans ce cours/wikilivres. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d'execution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, qui gère quelques modes d'adressage mineurs. L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
===La lecture des opérandes se fait en parallèle du décodage===
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Rien qui ne puisse être compensé par l'ajout des multiplications dans l'ALU, ceci dit. Mais le vrai problème est que le décodage devient beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Une solution alternative exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==L'optimisation du pipeline de données==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 6 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
===L'accès aux caches L1 est lent, mais se pipeline===
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants.
Supposons qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les autres étages se calaient dessus et s'adaptaient. Par exemple, si un accès au cache fait 50 µs, alors les autres étages essayaient de se rapprocher de 50 µs. Si un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15, alors on peut utiliser un étage ''Exec'' unique, pas besoin de scinder lecture des opérandes et exécution. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant.
L'unité de calcul peut en profiter pour faire des calculs complexes, comme les multiplication ou divisions. Précisons qu'il y a des pipelines qui gèrent les instructions multi-cycles, tous les processeurs modernes sont dans ce cas-là. Mais ils seront vus dans des chapitres à part. Pour le moment, nous devons nous concentrer sur les pipelines 1-cycle, où toutes les opérations se font en un seul cycle d'horloge.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
La solution précédente fait en sorte de se '''caler sur l'étage limitant''', quitte à réorganiser le pipeline. La répartition du travail entre étages est donc modifiée. L'inconvénient est qu'elle réduit le nombre d'étages, et donc le nombre d'instructions en vol. Mais une seconde technique permet au contraire de pipeliner encore plus le pipeline. L'idée est que les étages limitants sont eux-même pipelinés, scindés en circuits plus simples séparés par des registres.
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
iwwhya2pkeh20tlrdjcr1q16l3azf27
Fonctionnement d'un ordinateur/La prédiction de branchement
0
65955
774873
774605
2026-10-11T17:46:40Z
Mewtow
31375
/* L'usage de fonctions de hashage pour indexer les diverses SRAM/tables */
774873
wikitext
text/x-wiki
Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination.
L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution.
Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée.
[[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]]
Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct.
==Les branchements et les exceptions imprécises/précises==
Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline.
Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris.
Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort.
Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours.
===L'annulation des instructions au décodage===
Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes.
Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage.
===L'usage du système d'exceptions précises pour les branchements conditionnels===
La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci.
L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris.
Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements.
===La prédiction de branchement===
Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes :
* reconnaître les branchements ;
* savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ;
* dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''.
Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit.
[[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]]
: Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''.
Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects.
Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Un premier prédicteur fournit des résultats rapides et peu précis, un second a des résultats plus précis après quelques cycles d'horloge. Le prédicteur rapide envoit l'adresse prédite au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter''. Le prédicteur lent envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris.
Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. Ce détail aura rapidement son importance.
==Les erreurs de prédiction de branchement==
Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres.
Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée.
[[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]]
===La pénalité de mauvaise prédiction===
Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres
Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]]
Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement.
===La ''minimal control dependency''===
En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées.
Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin.
Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées.
Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction.
[[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]]
==La reconnaissance des branchements==
Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution.
La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache.
Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache
Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses.
* La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris.
* L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM.
L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit.
L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement.
==La prédiction de l'adresse de destination==
La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile).
: Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''.
Pour rappel, les branchements se classent en trois types :
* les branchements directs pour lequel l'adresse de destination est toujours la même ;
* les branchements indirects où l'adresse de destination est dans un registre et peut changer ;
* les instructions de retour de fonction.
Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux.
===La prédiction de destination pour les branchements directs===
Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects.
Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas.
Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents.
[[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]]
Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache.
Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB.
Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''.
Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées.
Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''.
Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant.
Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort.
[[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]]
Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1.
Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage.
{|class="wikitable"
|-
! rowspan="3" | Zen 2
| 16 || 0
|-
| 512 || 1
|-
| 7168 || 4
|-
! rowspan="3" | Zen 3
| 16 || 0
|-
| 1024 || 1
|-
| 6656 + 1536 (branchements indirects)|| 4
|}
===La prédiction de destination pour les branchements indirects===
Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement.
Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne.
Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique.
{|class="wikitable"
|+ Branch Target Buffer unifié
|-
! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination
|-
| 0x54462146 || 11101011 || 0x52495674 || 0x24987461
|-
| 0x54142446 || 11101101 || 0x15646956 || x0x4158746
|-
| 0x25456542 || 10011101 || 0x52495674 || 0x24157461
|-
| 0x15455855 || 11010101 || 0x52485754 || 0x24024511
|-
| ... || ... || ... || ...
|-
| ... || ... || ... || ...
|-
| 0x54462146 || 1001101 || 0x52495674 || 0x24987461
|}
Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement.
[[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]]
L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides.
Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations.
Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité.
Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ?
La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente.
La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement.
L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris.
Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord.
Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération.
Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus.
[[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]]
L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux.
===La prédiction de l'adresse de retour d'une fonction===
Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours.
Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour.
Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur.
La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse.
Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions.
La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction.
La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé.
Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit.
==La prédiction de branchement locale==
La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''.
La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses.
La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas.
Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent.
Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques.
La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales.
===Les ''branch hints''===
Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal.
La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre.
===La prédiction statique de branchement===
La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire.
Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique.
Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants.
Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit :
* les branchements inconditionnels sont toujours pris ;
* les branchements descendants sont toujours pris ;
* les branchements ascendants ne sont jamais pris.
L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''.
{|class="wikitable"
|+ BTB avec prédiction de branchement statique évoluée
|-
! Adresse du branchement !! Adresse de destination !! Type du branchement
|-
| 0x4165485 || 0x14657445541
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ...
| style="background: #F0F000" | Retour de fonction
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Inconditionnel
|-
| ... || ...
| style="background: #F0F000" | Conditionnel
|-
| ... || ... || ...
|}
===La prédiction avec des compteurs à saturation===
Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales.
Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante.
Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi.
Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%.
[[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]]
: Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale.
La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements.
L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB.
===La prédiction avec un historique local===
La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions.
Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique.
Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions.
Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception.
Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent.
Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites.
Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter.
Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit.
Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT.
[[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]]
Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''.
Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC.
[[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]]
Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux.
==La prédiction de branchement globale/mixte==
Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement.
L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction.
L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local.
[[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]]
Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux.
Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux.
[[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]]
Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances !
Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail.
===La mitigation des interférences par usage de l'adresse de branchement===
La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat.
Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique.
Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors.
[[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]]
D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées.
Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser.
[[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]]
Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''.
[[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]]
Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations.
Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation.
Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides.
Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement.
[[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]]
Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées.
[[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]]
===La mitigation des interférences par la structuration en caches des PHTs===
Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches.
Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique.
La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible.
Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels.
===La mitigation des interférences liées aux branchements biaisés===
L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale.
En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit.
Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''.
L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant.
[[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]]
La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale.
Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement.
[[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]]
Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris.
==La prédiction de branchement avec des perceptrons==
Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''.
Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables.
===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements===
L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même.
Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1.
Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci :
: <math>Z = w_0 + \sum_i (h_i \times w_i)</math>
[[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]]
: Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids.
Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante :
: <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris.
===L'implémentation matérielle des perceptrons et ses optimisations===
Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande.
Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle.
[[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]]
Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide.
Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM.
D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables.
Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit).
===Les avantages et inconvénients des perceptrons pour la prédiction de branchements===
L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent.
Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements.
La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante :
: <math>w_0 + \sum_i (h_i \times w_i) = 0</math>
Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes.
Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales.
Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure.
Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours.
==La prédiction des branchements des boucles==
Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois.
Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle.
Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance".
Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois.
Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité.
==La prédiction avec plusieurs unités de branchement distinctes==
Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions.
La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique.
Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité.
L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité.
[[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]]
Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier.
===Le prédicteur L-TAGE===
Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats.
Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique.
Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien.
Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près.
Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit.
==Les optimisations de la prédiction de branchements==
La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc.
===Le filtrage de branchements===
Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements.
Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions.
===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables===
Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées.
Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques.
Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers.
L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=Les optimisations du chargement des instructions
| nextText=Les optimisations du chargement des instructions
}}
</noinclude>
bs8ki1gujc7dbwbzv5cbhym7sc0xvxf
Fonctionnement d'un ordinateur/Sommaire
0
69596
774870
774784
2026-10-11T17:45:58Z
Mewtow
31375
/* Le parallélisme d’instructions */
774870
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines fixes|Les pipelines fixes]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
kqgawxzfsstbq1mgz1ifmuaravp88pl
774927
774870
2026-10-11T19:22:31Z
Mewtow
31375
/* L’exécution dans l'ordre et dans le désordre */
774927
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
7dlubbpvh8bxi87lsakqv1iw11z1q40
774985
774927
2026-10-11T23:00:39Z
Mewtow
31375
/* Le parallélisme d’instructions */
774985
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions|L'émission dans l'ordre des instructions]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
dto61qdfwsbmf6v07vtkt9p6rud6qov
774988
774985
2026-10-11T23:03:13Z
Mewtow
31375
/* Le parallélisme d’instructions */
774988
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions|L'émission dans l'ordre des instructions]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
k67yqid3u32g8jfjh7ppe07zubucw2b
774998
774988
2026-10-11T23:08:17Z
Mewtow
31375
/* Le parallélisme d’instructions */
774998
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
* [[Fonctionnement d'un ordinateur/L'émission des instructions|L'émission des instructions]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
iefq8l55l25noowndb1oxre4rhqzq1m
774999
774998
2026-10-11T23:10:32Z
Mewtow
31375
/* Le parallélisme d’instructions */
774999
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/L'émission des instructions|L'émission des instructions]]
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Les pipelines dynamiques|Les pipelines dynamiques]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
41v2tq8sjzs2qo4u53f8x55h2l9i0lt
775006
774999
2026-10-11T23:12:59Z
Mewtow
31375
/* L’exécution dans l'ordre et dans le désordre */
775006
wikitext
text/x-wiki
__NOTOC__
* [[Fonctionnement d'un ordinateur/Introduction|Introduction]]
==Le codage des informations==
* [[Fonctionnement d'un ordinateur/L'encodage des données|L'encodage des données]]
* [[Fonctionnement d'un ordinateur/Le codage des nombres|Le codage des nombres]]
* [[Fonctionnement d'un ordinateur/Les codes de détection/correction d'erreur|Les codes de détection/correction d'erreur]]
==Les circuits électroniques==
* [[Fonctionnement d'un ordinateur/Les portes logiques|Les portes logiques]]
===Les circuits combinatoires===
* [[Fonctionnement d'un ordinateur/Les circuits combinatoires|Les circuits combinatoires]]
* [[Fonctionnement d'un ordinateur/Les circuits de masquage|Les circuits de masquage]]
* [[Fonctionnement d'un ordinateur/Les circuits de sélection|Les circuits de sélection]]
* [[Fonctionnement d'un ordinateur/Les circuits incrémenteurs/décrémenteurs|Les circuits incrémenteurs/décrémenteurs]]
===Les circuits séquentiels===
* [[Fonctionnement d'un ordinateur/Les bascules : des mémoires de 1 bit|Les bascules : des mémoires de 1 bit]]
* [[Fonctionnement d'un ordinateur/Les circuits synchrones et asynchrones|Les circuits synchrones et asynchrones]]
* [[Fonctionnement d'un ordinateur/Les registres et mémoires adressables|Les registres et mémoires adressables]]
* [[Fonctionnement d'un ordinateur/Les compteurs et timers|Les compteurs et timers]]
* [[Fonctionnement d'un ordinateur/Les registres à décalage et les LSFR|Les registres à décalage et les LSFR]]
===Les circuits de calcul et de comparaison===
* [[Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation|Les circuits de décalage et de rotation]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition et la soustraction|Les circuits pour l'addition et la soustraction]]
* [[Fonctionnement d'un ordinateur/Les circuits de comparaison|Les circuits de comparaison]]
* [[Fonctionnement d'un ordinateur/Les unités arithmétiques et logiques entières (simples)|Les unités arithmétiques et logiques entières (simples)]]
* [[Fonctionnement d'un ordinateur/Les circuits pour l'addition multiopérande|Les circuits pour l'addition multiopérande]]
* [[Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division|Les circuits pour la multiplication et la division]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul logique et bit à bit|Les circuits de calcul logique et bit à bit]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul flottant|Les circuits de calcul flottant]]
* [[Fonctionnement d'un ordinateur/Les circuits de calcul trigonométriques|Les circuits de calcul trigonométriques]]
===Les circuits intégrés à semi-conducteurs===
* [[Fonctionnement d'un ordinateur/Les transistors et portes logiques|Les transistors et portes logiques]]
* [[Fonctionnement d'un ordinateur/Les circuits intégrés|Les circuits intégrés]]
* [[Fonctionnement d'un ordinateur/L'interface électrique entre circuits intégrés et bus|L'interface électrique entre circuits intégrés et bus]]
==L'architecture d'un ordinateur==
* [[Fonctionnement d'un ordinateur/L'architecture de base d'un ordinateur|L'architecture de base d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La hiérarchie mémoire|La hiérarchie mémoire]]
* [[Fonctionnement d'un ordinateur/La performance d'un ordinateur|La performance d'un ordinateur]]
* [[Fonctionnement d'un ordinateur/La loi de Moore et les tendances technologiques|La loi de Moore et les tendances technologiques]]
* [[Fonctionnement d'un ordinateur/Les techniques de réduction de la consommation électrique d'un processeur|Les techniques de réduction de la consommation électrique d'un processeur]]
==Les bus électroniques et la carte mère==
* [[Fonctionnement d'un ordinateur/La carte mère, chipset et BIOS|La carte mère, chipset et BIOS]]
* [[Fonctionnement d'un ordinateur/Les bus et liaisons point à point (généralités)|Les bus et liaisons point à point (généralités)]]
* [[Fonctionnement d'un ordinateur/Les encodages spécifiques aux bus|Les encodages spécifiques aux bus]]
* [[Fonctionnement d'un ordinateur/Les liaisons point à point|Les liaisons point à point]]
* [[Fonctionnement d'un ordinateur/Les bus électroniques|Les bus électroniques]]
* [[Fonctionnement d'un ordinateur/Quelques exemples de bus et de liaisons point à point|Quelques exemples de bus et de liaisons point à point]]
==Les mémoires RAM/ROM==
* [[Fonctionnement d'un ordinateur/Les différents types de mémoires|Les différents types de mémoires]]
* [[Fonctionnement d'un ordinateur/L'interface d'une mémoire électronique|L'interface d'une mémoire électronique]]
* [[Fonctionnement d'un ordinateur/Le bus mémoire|Le bus mémoire]]
===La micro-architecture d'une mémoire adressable===
* [[Fonctionnement d'un ordinateur/Les cellules mémoires|Les cellules mémoires]]
* [[Fonctionnement d'un ordinateur/Le plan mémoire|Le plan mémoire]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire interne|Le contrôleur mémoire interne]]
* [[Fonctionnement d'un ordinateur/Mémoires évoluées|Les mémoires évoluées]]
===Les mémoires primaires===
* [[Fonctionnement d'un ordinateur/Les mémoires ROM|Les mémoires ROM : Mask ROM, PROM, EPROM, EEPROM, Flash]]
* [[Fonctionnement d'un ordinateur/Les mémoires SRAM synchrones|Les mémoires SRAM synchrones]]
* [[Fonctionnement d'un ordinateur/Les mémoires RAM dynamiques (DRAM)|Les mémoires RAM dynamiques (DRAM)]]
* [[Fonctionnement d'un ordinateur/Contrôleur mémoire externe|Le contrôleur mémoire externe]]
===Les mémoires exotiques===
* [[Fonctionnement d'un ordinateur/Les mémoires associatives|Les mémoires associatives]]
* [[Fonctionnement d'un ordinateur/Les mémoires FIFO et LIFO|Les mémoires FIFO et LIFO]]
==Le processeur==
===L'architecture externe===
* [[Fonctionnement d'un ordinateur/Langage machine et assembleur|Langage machine et assembleur]]
* [[Fonctionnement d'un ordinateur/Les registres du processeur|Les registres du processeur]]
* [[Fonctionnement d'un ordinateur/Le modèle mémoire : alignement et boutisme|Le modèle mémoire : alignement et boutisme]]
* [[Fonctionnement d'un ordinateur/Les modes d'adressage|Les modes d'adressage]]
* [[Fonctionnement d'un ordinateur/L'encodage des instructions|L'encodage des instructions]]
* [[Fonctionnement d'un ordinateur/Les jeux d'instructions|Les jeux d'instructions]]
* [[Fonctionnement d'un ordinateur/La pile d'appel et les fonctions|La pile d'appel et les fonctions]]
* [[Fonctionnement d'un ordinateur/Les interruptions et exceptions|Les interruptions et exceptions]]
===La micro-architecture===
* [[Fonctionnement d'un ordinateur/Les composants d'un processeur|Les composants d'un processeur]]
* [[Fonctionnement d'un ordinateur/Le chemin de données|Le chemin de données]]
* [[Fonctionnement d'un ordinateur/L'unité de chargement et le program counter|L'unité de chargement et le program counter]]
* [[Fonctionnement d'un ordinateur/L'unité de contrôle|L'unité de contrôle]]
* [[Fonctionnement d'un ordinateur/L'implémentation matérielle des branchements|L'implémentation matérielle des branchements]]
===Les jeux d'instruction anciens===
* [[Fonctionnement d'un ordinateur/Les architectures à accumulateur|Les architectures à accumulateur]]
* [[Fonctionnement d'un ordinateur/Les architectures à pile et mémoire-mémoire|Les architectures à pile et mémoire-mémoire]]
* [[Fonctionnement d'un ordinateur/Les processeurs 8 bits et moins|Les processeurs 8 bits et moins]]
===L'espace d'adressage du processeur===
* [[Fonctionnement d'un ordinateur/L'espace d'adressage du processeur|L'espace d'adressage du processeur]]
* [[Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle|L'abstraction mémoire et la mémoire virtuelle]]
==Les entrées-sorties et périphériques==
* [[Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques|Les méthodes de synchronisation entre processeur et périphériques]]
* [[Fonctionnement d'un ordinateur/L'adressage des périphériques|L'adressage des périphériques]]
* [[Fonctionnement d'un ordinateur/Les périphériques et les cartes d'extension|Les périphériques et les cartes d'extension]]
==Les mémoires de stockage==
* [[Fonctionnement d'un ordinateur/Les mémoires de masse : généralités|Les mémoires de masse : généralités]]
* [[Fonctionnement d'un ordinateur/Les disques durs|Les disques durs]]
* [[Fonctionnement d'un ordinateur/Les solid-state drives|Les solid-state drives]]
* [[Fonctionnement d'un ordinateur/Les disques optiques|Les disques optiques]]
* [[Fonctionnement d'un ordinateur/Compléments sur les mémoires de masse|Compléments sur les mémoires de masse]]
==La ou les mémoires caches==
* [[Fonctionnement d'un ordinateur/Les mémoires cache|Les mémoires cache]]
* [[Fonctionnement d'un ordinateur/Le préchargement|Le préchargement]]
* [[Fonctionnement d'un ordinateur/Le Translation Lookaside Buffer|Le ''Translation Lookaside Buffer'']]
==Le parallélisme d’instructions==
* [[Fonctionnement d'un ordinateur/Le pipeline|Le pipeline]]
===Les branchements et le ''front-end''===
* [[Fonctionnement d'un ordinateur/La prédiction de branchement|La prédiction de branchement]]
* [[Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions|Les optimisations du chargement des instructions]]
===L’exécution dans l'ordre et dans le désordre===
* [[Fonctionnement d'un ordinateur/L'émission des instructions|L'émission des instructions]]
* [[Fonctionnement d'un ordinateur/Les pipelines in-order|Les pipelines in-order]]
* [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB|Le scoreboard et le ROB]]
* [[Fonctionnement d'un ordinateur/L'exécution dans le désordre|L'exécution dans le désordre]]
* [[Fonctionnement d'un ordinateur/Les schedulers et stations de réservation|Les schedulers et stations de réservation]]
* [[Fonctionnement d'un ordinateur/Le renommage de registres|Le renommage de registres]]
* [[Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations|Les schedulers larges et leurs optimisations]]
* [[Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo|Le scoreboarding et l'algorithme de Tomasulo]]
===Les optimisations des accès mémoire===
* [[Fonctionnement d'un ordinateur/La désambiguïsation mémoire|La désambiguïsation mémoire]]
* [[Fonctionnement d'un ordinateur/Le parallélisme mémoire|Le parallélisme mémoire]]
===Les processeurs superscalaires===
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires|Les processeurs superscalaires in-order]]
* [[Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order|Les processeurs superscalaires out-of-order]]
===Le parallélisme d'instruction explicite===
* [[Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC|Les processeurs VLIW et EPIC]]
* [[Fonctionnement d'un ordinateur/Les architectures dataflow|Les architectures dataflow]]
===Les processeurs x86===
* [[Fonctionnement d'un ordinateur/Les premiers processeurs Intel|Les premiers processeurs Intel]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : haute performance|Les microarchitectures pour le x86 : haute performance]]
* [[Fonctionnement d'un ordinateur/Les microarchitectures pour le x86 : basse consommation|Les microarchitectures pour le x86 : basse consommation]]
==Les architectures parallèles==
* [[Fonctionnement d'un ordinateur/Les architectures parallèles|Les architectures parallèles]]
* [[Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs|Les architectures multiprocesseurs et multicœurs]]
* [[Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading|Les architectures multithreadées et Hyperthreading]]
* [[Fonctionnement d'un ordinateur/Les architectures à parallélisme de données|Les architectures à parallélisme de données]]
* [[Fonctionnement d'un ordinateur/La cohérence des caches|La cohérence des caches]]
* [[Fonctionnement d'un ordinateur/Les sections critiques et le modèle mémoire|Les sections critiques et le modèle mémoire]]
==Annexes==
===Les nombres flottants : FPUs et coprocesseurs===
* [[Fonctionnement d'un ordinateur/Un exemple de jeu d'instruction : l'extension x87|Un exemple de jeu d'instruction : l'extension x87]]
* [[Fonctionnement d'un ordinateur/Les coprocesseurs : FPU et IO|Les coprocesseurs : FPU et IO]]
===Les jeux d’instruction spécialisés===
* [[Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation|Les ISA optimisés pour la compilation/interprétation]]
* [[Fonctionnement d'un ordinateur/Les processeurs de traitement du signal|Les processeurs de traitement du signal]]
* [[Fonctionnement d'un ordinateur/Les architectures actionnées par déplacement|Les architectures actionnées par déplacement]]
===Réseaux de neurones et accélérateurs d'IA===
* [[Fonctionnement d'un ordinateur/Les architectures systoliques|Les architectures systoliques]]
* [[Fonctionnement d'un ordinateur/Les architectures neuromorphiques|Les réseaux de neurones matériels]]
===Les autres annexes===
* [[Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation|L'accélération matérielle de la virtualisation]]
* [[Fonctionnement d'un ordinateur/Le matériel réseau|Le matériel réseau]]
* [[Fonctionnement d'un ordinateur/La tolérance aux pannes|La tolérance aux pannes]]
* [[Fonctionnement d'un ordinateur/Les circuits de conversion analogique-numérique|Les circuits de conversion analogique-numérique]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs de première génération : tubes à vide et mémoires|Les ordinateurs de première génération : tubes à vide et mémoires]]
* [[Fonctionnement d'un ordinateur/Les ordinateurs à encodages non-binaires|Les ordinateurs à encodages non-binaires]]
* [[Fonctionnement d'un ordinateur/Les circuits réversibles|Les circuits réversibles]]
{{autocat}}
fzl7t6jhzea7nhqkvm9kqsl0bln789i
Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions
0
79799
774874
774600
2026-10-11T17:47:19Z
Mewtow
31375
/* Le Fetch Directed Instruction Prefetching */
774874
wikitext
text/x-wiki
Les processeurs avec un pipeline sont découpés en deux sections : un ''front-end'' qui charge et décode les instructions, et un ou plusieurs ''back-end'' qui exécutent les instructions. L'exécution des micro-opérations est optimisée par des techniques que nous verrons dans la suite du cours : exécution dans le désordre, superscalarité, renommage de registre. Mais un point important est que le ''front-end'' a des optimisations dédiées. La prédiction de branchement est l'une de ces optimisation, mais elle n'est pas la seule. Ce chapitre aborde les optimisations autres que la prédiction de branchement. Nous allons voir le découplage des étages du ''front-end'', le pré-décodage et quelques améliorations liées indirectement à la prédiction de branchement.
==Le prédécodage d'instructions==
La présence d'un cache d'instruction permet l'implémentation de certaines optimisations, dont la plus connue est la technique dite du '''prédécodage'''. Avec elle, lorsque les instructions sont chargées dans le cache d'instruction, elles sont partiellement décodées, grâce à un circuit séparé de l'unité de décodage d'instruction. Le décodage de l'instruction proprement dit est plus court, car une partie du travail est faite en avance, on gagne quelques cycles.
[[File:Prédécodage des instructions dans le cache L1.png|centre|vignette|upright=2.5|Prédécodage des instructions dans le cache L1]]
Le prédécodage est particulièrement utile avec des instructions de taille variable : il permet de pré-déterminer où commencent/terminent les instructions dans une ligne de cache, indiquer leur taille, etc. Autre possibilité, le prédécodage peut indiquer s'il y a des branchements dans une ligne de cache et où ils se trouvent, ce qui est très utile pour la prédiction de branchement.
Pour chaque ligne de cache, le décodage partiel fournit des informations utiles au décodeur d'instruction. Les informations pré-décodées sont soit intégrée dans la ligne de cache, soit mémorisées dans une banque séparée. En clair : une partie de la capacité totale du cache d'instruction est utilisée pour les informations de pré-décodage. Le prédécodage est donc un compromis : un cache d'instruction de plus faible capacité, mais un décodage plus simple.
Le pré-décodage est surtout utile pour les instructions qui sont ré-exécutées souvent. Pour les instructions exécutées une seule fois, le gain en performance dépend de l'efficacité du préchargement et d'autres contraintes, mais ce qui est gagné lors du décodage est souvent partiellement perdu lors du prédécodage. Par contre, si une instruction est exécutée plusieurs fois, le pré-décodage est fait une seule fois, alors qu'on a un gain à chaque ré-exécution de l'instruction.
===Les sélecteurs de branchement intégrés au cache L1===
Le pré-décodage peut être utilisé afin de faciliter le travail de la prédiction de branchement. L'idée est d'incorporer une partie de la prédiction de branchement dans le cache L1 d'instruction. Une ligne de cache mémorise alors des informations de prédiction de branchement dans ses bits de contrôle. Les informations en question peuvent être des adresses de destination, ou simplement de quoi déterminer si le branchement est pris ou non. Il s'agit d'une démarche inverse à celle de la ''Fetch Target Queue'', qui découple l'unité de prédiction de branchement du cache, en insérant une mémoire FIFO entre les deux. Là, la démarche est au contraire de fusionner partiellement cache d'instruction et unité de prédiction de branchement.
Les premiers processeurs AMD utilisaient cette technique, au moins dans les grandes lignes. Une ligne de cache contient potentiellement plusieurs branchements, dont la position est identifiée par le prédécodage. Pour chaque octet, la ligne de cache associe un bit de contrôle qui indique si un branchement démarre à cet octet, si c'est le premier octet d'un branchement. Le prédécodage peut identifier entre un et plusieurs branchement par ligne de cache, il y a une limite. Le prédécodage n'identifie typiquement que les 3 à 5 premiers branchements, les suivants sont ignorés, faute de place dans les bits de contrôle.
Prenons par exemple une ligne de cache de 8 octets, dans laquelle on a 2 branchements de 2 octets chacun.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Branch 1 || bgcolor="#FFFF00" | Branch 1 || Instruction || bgcolor="#FFFF00" | Branch 2 || bgcolor="#FFFF00" | Branch 2 || Instruction || Instruction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 0 || 1 || 0 || 0 || 1 || 0 || 0 || 0
|}
Il est possible d'améliorer le tout en précisant quel est le type du branchement. Par exemple, on peut distinguer les branchements inconditionnel et conditionnels, ou encore les instruction de retour de fonction. L'intérêt n'est pas évident, mais c'est lié au fait que les branchements inconditionnels sont toujours pris, et que les retour de fonction ont une adresse de destination qui est prédite par une unité de branchement séparée, le ''return adress predictor'', pas par un BTB. Deux bits suffisent pour indiquer : si c'est un branchement conditionnel, inconditionnel, un retour de fonction, ou une instruction qui n'est pas un branchement.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Saut inconditionnel || bgcolor="#FFFF00" | Saut inconditionnel || Instruction || bgcolor="#A00000" | Branch cond || bgcolor="#A00000" | Branch cond || Instruction || bgcolor="#F0F000" | Retour de fonction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 00 || 01 || 00 || 00 || 10 || 00 || 00 || 11
|}
L'idée est alors d'ajouter, pour chaque branchement détecté, un '''sélecteur de branchement''' qui indique si le branchement est pris ou non. En clair, des informations de prédiction de branchement sont ajoutés à chaque octet de position. Intuitivement, on se dit qu'il y a seulement un bit par branchement, qui indique si le branchement est pris ou non.
Les prédictions peuvent venir soit de l'unité de prédiction de branchement, soit provenir du prédécodage. Le prédécodage peut faire de la prédiction statique. Elle peut notamment détecter les branchements inconditionnels et les marquer comme pris. Elle peut aussi détecter les branchements conditionnels et le marquer comme non-pris par défaut. L'unité de prédiction de branchement met à jour les sélecteurs de branchements si besoin, pour les branchements conditionnels.
===L'incorporation du ''Branch Target Buffer'' dans le cache d'instruction===
Une première optimisation permet de se passer de ''Branch Target Buffer''. Pour rappel, celui-ci est un cache qui mémorise, pour chaque branchement, quelle est son adresse de destination. Il peut contenir d'autres informations de prédiction, mais laissons-les de côté pour le moment.
L'idée est de déplacer les adresse de destination des branchements dans le cache d'instruction, dans les lignes de cache. Si une ligne de cache contient un branchement, elle mémorise l'adresse de destination de ce branchement, en plus des bits de pré-décodage. En général, les processeurs ne supportent qu'une seule adresse de destination. Si il y a plusieurs branchements dans une ligne de cache, c'est l'adresse de destination du premier branchement pris dans cette ligne de cache qui est mémorisée. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela.
Il faut cependant remarquer qu'à ce petit jeu, les instructions de retour de fonction sont à part. Leur adresse de destination est souvent donnée par une unité de branchement séparée, le ''return adress predictor'', séparée du ''Branch Target Buffer''. Leurs adresses de destination n'ont pas forcément besoin d'être mémorisées dans les lignes de cache.
La technique décrite ici est simple à comprendre. Cependant, les processeurs AMD anciens, d'architecture K6 à K10 n'utilisaient pas cette méthode, mais une variante plus complexe, capable de prédire jusqu'à deux adresses de destination par branchement. A partir de l'architecture K6, le prédécodage déterminait la position des branchements dans les lignes de cache, dans une limite de 4 branchements par ligne de cache. Pour chaque branchement, la ligne de cache mémorisait un sélecteur de branchement, codé sur 2 bits. La valeur des bits indiquait que le branchement n'est pas pris si elle vaut 00, que c'est une instruction de retour de fonction si elle vaut 01, qu'il faut brancher à l'adresse de destination X si elle vaut 10, qu'il faut brancher à l'adresse de destination X si elle vaut 11. Les adresses de destination sont quand à elles mémorisées dans un cache séparé, appelé le ''Branch Target Cache''. Le mécanisme pour adresser ce cache à partir du cache d'instruction n'est pas très détaillé dans la documentation d'AMD.
===Les avantages et inconvénients===
L'avantage de faire ainsi est que la prédiction de branchement est plus rapide. Lire une instruction depuis le cache renvoie non seulement l'instruction lue, mais aussi des informations de prédiction de branchement. L'unité de prédiction de branchement peut alors utiliser ces informations au cycle suivant pour savoir quelle est l'instruction suivante à charger.
Un défaut de cette approche est que si le branchement à prédire n'est pas dans le L1 d'instruction, aucune prédiction de branchement ne peut être faite et le préchargement ne peut pas fonctionner. C'est une limitation que n'ont pas les BTB découplées du cache L1 : elles peuvent prédire les adresses de destination et la direction d'un branchement, tant que l'entrée associée est dans le BTB. Et l'entrée peut être conservée, même si l'instruction en question a quitté le cache L1 et qu'elle est dans le L2, le L3 ou même en mémoire RAM. Les prédictions peuvent même servir à précharger les instructions utiles.
Sur l'Itanium et l'AMD Opteron, une optimisation assez intéressante permet de conserver les prédictions de branchement lorsque l'un branchement est évincé du cache L1 et se retrouve dans le cache L2. En théorie, les informations de prédiction, présentes dans la ligne de cache, sont perdues lorsque le branchement est évincé. Mais ces processeurs conservent ces prédictions dans un cache séparé, appelé le '''''L2 Branch Cache'''''.
==Le découplage du ''front-end''==
Le séquenceur d'un processeur contient une unité de chargement, un décodeur et un chemin de données. Avec un pipeline, le couplage de ces structures fait que si l'une d'entre elle prend plus d'un cycle pour faire son travail, les étages précédents et/ou suivants sont stoppés. Par exemple, lors d'un défaut dans le cache d'instruction, l'ensemble stoppe. De même, si jamais le décodeur doit décoder une instruction via le micro-code, cela prend plusieurs cycles : l'unité de chargement et le cache d'instruction sont inutilisés. Même chose si jamais une instruction multicycle s’exécute dans le pipeline : cela bloque toutes les étapes précédentes.
Mais il s'agit là d'un défaut inhérent aux pipelines basiques, qui relient chaque étage avec des registres. Avec un peu d'astuce, il est possible que certains étages prennent de l'avance même si l'étage suivant est bloqué. Par exemple, si le décodeur bloque le pipeline en utilisant son micro-code pendant 4-5 cycles, l'unité de chargement peut en théorie précharger à l'avance les instructions suivantes, et les mettre en attente. Ou encore, en cas de bulle de pipeline (''pipeline stall''), le décodeur peut décoder en avance des instructions et les mettre en attente tant que la bulle de pipeline est en cours.
Dans les deux cas, une unité prend de l'avance et met en attente ses résultats tant que l'étage suivant est occupé. La mise en attente est réalisée en remplaçant/complémentant les registres du pipeline avec des mémoires FIFOs. Elles remplacent ou complémentant les registres de pipeline, les deux sont possibles. Si elles remplacent ces registres, si aucune mise en attente n'est requise, elles fonctionnent comme un registre de pipeline.
===La file d'instruction===
[[File:File de micro-opérations.png|vignette|upright=1|File d'instructions]]
Les processeurs modernes intègrent une '''file d'instruction''', à savoir une mémoire FIFO placée entre le cache d'instruction et le décodeur d'instruction. Les instructions chargées par l'étape de chargement soient accumulées dans la file d'instructions et sont décodées quand l'unité de décodage est prête. La file d'attente permet de charger des instructions à l'avance, permettant ainsi de masquer certains accès au cache ou à la mémoire assez longs.
L'idée est que les instructions s'accumulent dans la file d'instruction si le processeur exécute les instructions moins vite qu'il ne les charge. C'est généralement signe qu'il effectue une instruction multicycle et/ou qu'il effectue un accès à la mémoire. À l'inverse, la file d'attente se vide quand le processeur exécute les instructions plus vite qu'il n'en charge. C'est généralement signe qu'un défaut de cache d'instruction est en cours. La présence d'une file d'attente fait que la première situation est compensée lors de la seconde. Les temps d'attentes liées aux instructions multicycles permettent de remplir la file d'attente, qui est ensuite vidée en cas de défaut de cache. Le processeur exécute en permanence des instructions, sans interruption. Alors que sans file d'attente, les défauts de cache entraineront des temps d'attente où le processeur s’exécuterait rien.
La seule limite de cette optimisation est l'influence des branchements. Lorsqu'un branchement est décodé, ce tampon d’instructions est totalement vidé de son contenu. Ce n'est ni plus ni moins ce que faisait la ''prefetch input queue'' des anciens processeurs Intel, dont nous avions parlé dans le chapitre sur l'unité de chargement et le séquenceur.
===La file de micro-opérations===
[[File:File de micro-opération.png|vignette|upright=1|File d'instruction]]
L'optimisation précédente peut aussi s'appliquer entre le décodeur d'instruction et le chemin de données. Pour cela, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction. Elle mémorise les micro-opérations émises par le décodeur et les met en attente tant que le reste du pipeline n'est pas prêt. Nous l'appellerons la '''file de micro-opérations''', mais elle porte de nombreux noms et la terminologie des différents fabricants est assez confuse. Le schéma ci-contre indique que la file de micro-opérations est située en sortie de l’unité de décodage, avant l'unité d'émission et avant l'unité de renommage de registres (que nous aborderons dans quelques chapitres).
La file de micro-opérations permet aux décodeurs de faire leur travail même si le reste du pipeline n'est pas prêt. Par exemple, imaginons que le processeur ne peut pas émettre de nouvelle instruction, soit car toutes les ALUs sont occupées, soit car il y a un accès mémoire qui bloque le pipeline, peu importe. Sans file de micro-opérations, tout ce qui précède l'unité d'émission devrait être totalement bloqué tant que l'instruction ne peut pas être émise. Mais avec une file de micro-opérations, le pipeline peut charger et décoder des instructions, il a juste à accumuler les instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions décodées quand des bulles de pipeline sont émises.
Et à l'inverse, elle permet d'émettre des instructions quand les unités de décodage/chargement sont bloquées. Le cas classique est celui d'un défaut de cache dans le cache d'instruction. Pendant un défaut de cache, aucune instruction n'est chargée ni décodées durant quelques cycles. Sans file de micro-opérations, le processeur ne peut plus rien faire durant quelques cycles. Mais avec une file de micro-opérations, il se rattrape en émettant les instructions en attente dans la file de micro-opérations, s'il y en a. En clair, si l'unité d'émission a mis en attente des instructions, le processeur se rattrape au prochain défaut de cache d'instruction.
Une autre situation où le décodeur bloque est le cas où certaines instructions mettent du temps à être décodées. C'est notamment le cas de certaines instructions complexes, dont le décodage prend facilement 2 à 3 cycles d'horloge, voire plus. Le pire est le décodage des instructions microcodées, qui peut demander plusieurs cycles. Or, le pipeline demande qu'on décode une instruction par cycle pour éviter de bloquer le pipeline. Mais ce temps de décodage peut être masqué si des micro-opérations sont en attente dans la file, elles sont exécutées pendant le décodage long.
===Le ''Loop Stream Detector''===
Les boucles sont une opportunité d'optimisation très intéressante sur les CPU avec une file de micro-opérations. L'idée est que lors d'une boucle, des instructions sont chargées, décodées et exécutées plusieurs fois de suite. Mais à chaque répétition d'une instruction, le chargement et le décodage donnent toujours le même résultat, seule l'exécution n'est pas la même (les registres renommés sont aussi différents, mais nous verrons cela dans plusieurs chapitres). L'idée est simplement de mémoriser les N dernières instructions décodées et de les ré-exécuter si besoin. Ainsi, on évite de charger/décoder une même instruction machine plusieurs fois, mais de réutiliser les micro-opérations déjà décodées.
L'implémentation la plus simple utiliser la file de micro-opérations comme une sorte de pseudo-cache FIFO. La file de micro-opérations ne supprime pas les micro-opérations une fois qu'elles sont émises. Elle mémorise là où se trouve la dernière micro-opération émise, mais conserve celles qui ont déjà été émises. Un circuit annexe, appelé le '''''Loop Stream Detector''''' (LSD), détecte les boucles dans la file de micro-opérations et optimise leur exécution. Si une boucle adéquate est détectée par le ''Loop Stream Detector'', les micro-opérations de la boucle sont lues dans la file de micro-opération et sont injectées directement dans la suite du pipeline. De plus, les unités de chargement et de décodage sont désactivées pendant l’exécution de la boucle, ce qui réduit la consommation d'énergie du CPU.
L'optimisation accélère les petites boucles, qui tiennent toutes entières dans la file de micro-opérations, et sous condition qu'elles s'exécutent de la même manière à chaque exécution. De telles boucles exécutent une suite de N instructions, qui reste identique à chaque itération de la boucle. Le cas le plus simple est celui d'une boucle dans laquelle il n'y a pas de branchements. Pour les boucles normales, le processeur reprend une exécution normale quand on quitte la boucle ou quand son exécution change, par exemple quand un if...else, un return ou tout autre changement de flot de contrôle a lieu. Vu que toutes ces situations impliquent un branchement qui n'a pas été pris comme avant, le processeur n'utilise plus le ''Loop Stream Detector'' en cas de mauvaise prédiction de branchement.
Le LSD vise surtout à désactiver les décodeurs et l'unité de chargement lors de l'exécution d'une boucle. La désactivation peut être du ''clock gating'', voire du ''power gating'', être partielle ou totale. Dans le pire des cas, les unités de chargement peuvent continuer à charger des instructions en avance dans une file d'instruction, mais les décodeurs peuvent être désactivés. Dans le meilleur des cas, la totalité de ce qui précède la file de micro-opération est désactivé tant que la boucle s’exécute normalement. Y compris le cache de micro-opération.
[[File:Loop Stream Detector.png|centre|vignette|upright=2|Loop Stream Detector]]
Évidemment, la taille des boucles optimisées ainsi est limitée par la taille de la file de micro-opération, ce qui fait que l'optimisation ne fonctionne que pour des boucles de petite taille. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 micro-opérations. De plus, toute la file de micro-opération n'est pas gérée par le ''loop stream detector''. Par exemple, les processeurs avec une file de micro-opération de 64 micro-opération peuvent gérer des boucles de maximum 32 à 40 micro-opérations.
Mais les contraintes principales portent sur la détection des boucles. Le ''Loop Stream Detector'' ne peut pas détecter toutes les boucles qui existent, et certaines boucles ne sont pas détectées. Par exemple, le ''Loop Stream Detector' ne peut pas détecter les boucles si un appel de fonction a lieu dans la boucle. Il y a aussi des contraintes quant au nombre de branchements à l'intérieur de la boucle et le nombre d'accès mémoire.
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les micro-architectures Zen 4 mais il a disparu sur la micro-architecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la micro-architecture Zen 4 d'AMD ou les CPU de micro-architecture Skylake et Kaby Lake d'Intel. Pour la micro-architecture Skylake, les raisons officielles pour cette désactivation sont un bug lié à l'interaction avec l'''hyperthreading''. Il est vraisemblable que des bugs ou des problèmes de sécurité aient amené à la désactivation sur les autres architectures.
===Le cache de micro-opérations===
Le '''cache de micro-opérations''' a le même but que le ''Loop Stream Detector'', à savoir optimiser l'exécution des boucles. La différence avec le ''Loop Stream Detector'' est qu'il y a un cache séparé de la file de micro-opérations, qui mémorise des micro-opérations décodées, dans le cas où elles soient réutilisées par la suite. La première itération d'une boucle accumule les instructions décodées dans le cache de micro-opérations, les itérations suivantes de la boucle lisent les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois. Sur de nombreux processeurs, le cache de micro-opération est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ainsi, seules les micro-opérations émises sont copiées dans ce cache.
[[File:File de micro-opérations et cache de micro-ops.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
Les avantages sont les mêmes qu'avec un ''Loop Stream Detector'' : une consommation énergétique réduite, des performances légèrement améliorées. Le décodeur et l'unité de chargement sont inutiles en cas de succès dans le cache de micro-opération, ce qui fait qu'ils sont désactivés, éteints, ou du moins subissent un ''clock-gating'' temporaire. Ils ne consomment pas d'énergie, seul le cache de micro-opération utilise de l'électricité. L'avantage en termes de performance est plus faible, assez variable suivant la situation, mais aussi bien le cache de micro-opérations que le LSD ne font pas de mal.
Une différence avec le cache de micro-opération est qu'une boucle doit s’exécuter à l'identique avec un ''Loop Stream Detector'', pas avec un cache de micro-opérations. Prenons l'exemple d'une boucle contenant quelques instructions suivies par un IF...ELSE. Il arrive qu'une itération de la boucle exécute le IF, alors que d'autres exécutent le ELSE. Dans ce cas, le ''Loop Stream Detector'' ne sera pas activé, car la boucle ne s’exécute pas pareil d'une itération à l'autre. Par contre, un cache de macro/micro-opération mémorisera les micro-opérations du IF et du ELSE et les fournira selon les besoins. La raison est que le cache de micro-opérations a une politique de remplacement des lignes de cache plus complexe que le FIFO, typiquement une politique LRU ou LFU approximée. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', pour un cout en transistor plus élevé.
Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. L'accès au cache de micro-opération se fait lors de l'étape de chargement. Le cache de micro-opérations est adressé en envoyant le ''program counter'' sur son entrée d'adresse, en parallèle du cache d'instruction. En clair, il y a une voie qui regroupe cache d'instruction, file d'instruction et décodeur, et une seconde voie qui se résume au cache de micro-opération. Les deux voies sont accédées en parallèle. En cas de succès dans le cache de micro-opération, les micro-opérations adéquates sont lues directement depuis le cache de micro-opération.
Le cache de micro-opération associe, pour chaque instruction machine, une ou plusieurs micro-opérations. Avec l'implémentation la plus simple, une ligne de cache est associée à une instruction machine. Par exemple, sur les processeurs Intel de micro-architecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. Une instruction devait tenir toute entière dans une ligne de cache, ce qui fait que les instructions décodées en plus de 6 micro-opérations ne pouvaient pas rentrer dans ce cache.
Il existe deux méthodes différentes pour encoder les micro-opérations dans le cache de micro-opérations. La première est la plus intuitive : on mémorise les micro-opérations dans la ligne de cache, directement. Elle est utilisée sur les processeurs AMD, et sans doute sur les processeurs Intel récents. Mais les anciens processeurs Intel, comme ceux des architectures Sandy Bridge et Netburst, utilisent une autre méthode. Une ligne de cache mémorise non pas les micro-opération directement, mais un pointeur vers le ''control store'', qui indique à quelle adresse dans le micro-code se situe la micro-opération. La micro-opération est donc lue depuis le micro-code lors de son envoi au chemin de données, lors de son émission.
Il faut noter que pour des raisons de performance, le cache de micro-opérations est virtuellement tagué, ce qui fait qu'il est invalidé en cas de changement de programme. Sur l'architecture Sandy Bridge, il est carrément inclus dans le cache L1, les deux sont des caches inclusifs l'un avec l'autre. Les premières implémentations étaient très limitées. Les micro-opérations devaient être séquentielles dans le code, le cache était consulté seulement après un branchement et non à chaque instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Le cache de micro-opérations et le ''Loop Stream Detector'' font la même chose, mais certains processeurs implémentaient les deux. L'avantage est que le cache de micro-opération peut être désactivé si jamais le LSD détecte une boucle dans la file d'instruction, ce qui réduit encore plus la consommation énergétique. En pratique, l'impact sur la consommation énergétique est très difficile à mesurer, mais il rajoute de la complexité pour la conception du processeur.
[[File:File de micro-opérations et cache de micro-ops - Copie.png|centre|vignette|upright=2.5|File de micro-opérations et cache de micro-ops - Copie]]
===La ''Fetch Target Queue''===
Nous venons de voir qu'il est possible de découpler les étages de chargement, décodage et le chemin de données, en insérant des mémoires FIFOs dans le pipeline. Il en est de même avec l'unité de chargement elle-même. Elle est composée de deux circuits qu'on peut découpler : le cache d'instruction, l'unité de prédiction de branchement, et l'unité de calcul d'adresse. L'unité de calcul d'adresse regroupe : le ''program counter'', de quoi l'incrémenter, les MUX associés pour gérer les branchements.
Les processeurs modernes découplent l'unité de calcul d'adresse du cache d'instruction, en insérant une mémoire FIFO entre les deux. Les premiers articles scientifiques, qui ont proposé cette solution, l'ont appelée la '''''Fetch Target Queue''''', abréviée FTQ. Elle accumule les adresses à lire dans le cache d'instruction, peu importe que ces adresses viennent du ''program counter'' ou de l'unité de prédiction de branchement.
[[File:Fetch target queue.png|centre|vignette|upright=2.5|Fetch target queue]]
Elle se remplit quand le cache d'instruction est bloqué, soit à cause d'un défaut de cache, soit à cause d'un pipeline bloqué en amont de l'unité de chargement. Par exemple, si le cache d'instruction est bloqué par un défaut de cache, l'unité de prédiction de branchement peut accumuler des prédictions à l'avance dans la FTQ, qui sont ensuite consommées par le cache d'instruction une fois qu'il est redevenu disponible. De même, si l'unité de prédiction de branchement est bloquée par un évènement quelconque, le cache d'instruction peut consommer les prédictions faites à l'avance.
Une utilisation assez originale de la FTQ s'est vu sur les processeurs AMD d'architectures bulldozer. Sur cette architecture, les cœurs étaient regroupés par paquets de deux, et les deux cœurs partageaient certains circuits. Notamment, l'unité de prédiction de branchement était partagée entre les deux cœurs ! Pourtant, chaque cœur disposait de sa propre FTQ !
==Le ''Branch Folding'' et la file d'instruction==
Une file d'instruction basique précharge les instructions qui suivent la dernière instruction chargée. Mais une telle file d'instruction gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées. De plus, les instructions préchargées par erreurs doivent être supprimées pour éviter qu'elles soient décodées et exécutées, ce qui fait que la file d’instruction doit être invalidée.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Heureusement, il existe des techniques qui visent à réduire l'impact des branchements sur la file d'instruction. L'idée est d'exécuter les branchements à l'avance, avant même leur décodage ! L'optimisation en question est appelée le '''''Branch Folding''''', et a été utilisé sur les processeurs PowerPC, pour ne citer qu'eux.
Le ''branch folding'' a une ressemblance très forte avec le préchargement appliqué aux instructions. Il faut dire qu'une file d'instruction a pour rôle de charger des instructions ''à l'avance''. La différence est la cible : dans la file d'instruction pour le ''branch folding'', dans le cache pour le préchargement d'instruction. Pour faire la distinction avec les autres formes de préchargement, nous parlerons de '''préchargement interne'''.
Les algorithmes utilisés pour le préchargement interne sont cependant les mêmes que pour le préchargement d'instruction. Aussi, nous allons les voir en même temps. Le préchargement séquentiel a pour équivalent... la file d'instruction elle-même. Du moins, dans son fonctionnement normal. Voyons maintenant les autres algorithmes.
===Le ''target line prefetching''===
Une version améliorée prend en charge les branchements d'une manière un peu plus naturelle. Elle agit comme le préchargement séquentiel, sauf quand elle rencontre un branchement inconditionnel. Dans ce cas, elle précharge les instructions à partir de l'adresse de destination du branchement. Il s'agit du '''''target line prefetching'''''.
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
La technique demande de détecter les branchements inconditionnels. Pour cela, une unité spécialisée scanne la file d'instruction, pour y trouver les branchements. L'unité en question s'appelle l''''unité de ''branch folding'''''. Si un branchement inconditionnel est détecté dedans, elle utilise le ''target line prefetching''. L'adresse de destination est extraite du branchement inconditionnel, et le préchargement continue à partir de cette adresse. Pour cela, le ''program counter'' est altéré en avance. De plus, le branchement est remplacé par l'instruction de destination du branchement. Les instructions qui "suivent" le branchement sont annulées avant, histoire d'éliminer les instructions chargées à tord.
[[File:Branch folding.png|centre|vignette|upright=2|Branch folding.]]
Maintenant, qu'en est-il des branchements conditionnels ? La réponse de si la prédiction de branchement est utilisée ou pas. Sans prédiction de branchement, les branchements conditionnels sont considérés comme non-pris. Avec prédiction de branchement, tout dépend de si le branchement est prédit comme pris ou non. Si le branchement est considéré comme pris, il est traité comme un branchement inconditionnel : il est remplacé par l'instruction de destination et le ''program counter'' est altéré. Si le branchement est non-pris, il suffit de ne rien faire et de continuer le préchargement séquentiel.
Notez qu'il faut que l'adresse de destination soit connue à l'avance, ou du moins puisse être calculée facilement (branchements relatifs). L'unité de branchement peut contenir un additionneur afin de calculer l'adresse de destination d'un branchement relatif.
===Le préchargement du mauvais chemin===
Le ''target line prefetching'' peut être associé avec le préchargement séquentiel, pour gérer les branchements conditionnels au miuex. L'idée est de précharger à la fois les instructions après le branchement et celles à sa destination. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates auront été préchargées. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching''). La technique était implémentée sur le mainframe IBM 370/165 et quelques modèles IBM similaires. Les processeurs PowerPC utilisaient aussi cette technique.
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le préchargement du mauvais chemin demande d'utiliser deux files d'instructions séparées. L'une dans laquelle on précharge de manière séquentielle, l'autre utilisant la ''target line prefetching''. Le choix de la bonne file se fait avec un multiplexeur. Une fois le branchement conditionnel exécuté, on sait s'il est pris ou non, le contenu de la file adéquate est conservé, l'autre est intégralement invalidée.
[[File:Branch folding avec prédiction du mauvais chemin.png|centre|vignette|upright=2|''Branch folding'' avec prédiction du mauvais chemin.]]
Le préchargement du mauvais chemin réduit un peu la pénalité en cas de mauvaise prédiction de branchement, en économisant quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque charger l'instruction de destination déclenche un défaut de cache. Le préchargement déclenche alors le défaut de cache en avance de quelques cycles, qui peuvent être traité avant même que la mauvaise prédiction de branchement soit détectée.
Elle a le défaut de bouffer beaucoup de débit binaire, au niveau du cache d'instruction. Elle fonctionne mieux si l'on peut précharger deux instructions par cycle, une par chemin. Et cela demande un cache d'instruction double port, dont le cout en circuit n'est pas négligeable. Et le gain en performance est assez faible.
Les premiers processeurs PowerPC utilisaient cette optimisation. Ils avaient une file d'instruction de 8 instructions, et l'unité de branchement scannait les 4 premières instructions. L'unité de ''branch folding'' contenait les registres nécessaires pour gérer les branchements. Elle contenait précisément le ''link register'' pour des appels de procédures, le ''Count Target Register'' et le ''Count Register'' utilisés pour les boucles. Elle contient aussi un additionneur pour calculer les adresses des branchements relatifs.
L'unité de ''branch folding'' identifiait les branchements, sans décoder les instructions en attente. A la place, les instructions étaient pré-décodées dans le cache d'instruction, qui identifiait la position des branchement. Les informations de prédécodage étaient utilisées pour remplir la file d'instruction. Chaque instruction dans la file d'instruction est associée à un bit qui indique si c'est un branchement ou non. L'unité de branchement anticipé peut alors détecter les branchements avec un simple encodeur à priorité.
[[File:Branch folding des CPU PowerPC.png|centre|vignette|upright=2|Branch folding des CPU PowerPC - les bits de prédécodage sont en couleur. Les branchement sont en rouge, les autres instructions en jaune.]]
===Le préchargement du mauvais chemin avec plusieurs branchements consécutifs===
Un défaut du préchargement du mauvais chemin est qu'elle gère mal le cas où deux branchements sont assez proches dans le code machine. Un branchement scinde le flux d'instructions : un où le branchement est pris, un autre où il ne l'est pas. Chacun de ces flux peut lui-même contenir un branchement, et se scinder lui aussi. Et ainsi de suite. Et le processeur doit gérer cette situation en termes de préchargement.
[[File:Exécution stricte 04.png|centre|vignette|upright=2|Exécution stricte]]
Plusieurs solutions existent. La première est de ne pas utiliser le ''target line prefetching'' au-delà de deux branchements. La méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Une solution un peu moins intuitive utilise le préchargement du mauvais chemin pour le premier branchement, mais bascule sur le préchargement séquentiel pour les branchements suivants. Cette solution est celle qui est systématiquement implémentée, car elle est simple à implémenter. Une dernière possibilité est d'utiliser la prédiction de branchement au-delà du premier branchement, afin de poursuivre le chargement de manière spéculative.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
Une autre possibilité consiste à utiliser le préchargement du mauvais chemin pour le second branchement, ainsi que ceux des flux extérieurs. Cette dernière est impraticable car elle demande des caches avec un grand nombre de ports et la présence de plusieurs files d'instructions, qui sont utilisées assez rarement. Et quitte à gaspiller autant de transistors, autant les utiliser décoder/exécuter directement les instructions, au lieu de les accumuler dans des files d'instruction. C'est ce qu'on appelle l''''exécution stricte''' (''eager execution'').
Quelques papiers de recherche ont étudié l'idée, mais ses défauts font qu'elle n'a jamais été utilisée dans un processeur en dehors de prototypes de recherche. Le gros problème de l'exécution stricte est qu'on est limité par le nombre d'unités de calculs, de registres, etc. Autant ce serait une technique idéale sur des processeurs avec un nombre illimité de registres ou d'unités de calcul, autant ce n'est pas le cas dans le monde réel. Au bout d'un certain nombre d’embranchements, le processeur finit par ne plus pouvoir poursuivre l’exécution, par manque de ressources matérielles et doit soit stopper, soit recourir à la prédiction de branchement.
==Le préchargement d'instructions==
Dans le chapitre sur le préchargement, nous avons volontairement mis de côté les techniques de ''préchargement d'instruction'', car ce n'était pas le moment d'en parler. Mais le moment est venu d'aborder le préchargement pour les instructions, d’où cette section. La section précédente parlait d'une forme de préchargement qui n'en est pas vraiment, car elle n'implique pas de mémoire cache. Par contre, le préchargement que nous allons voir le fait. Le '''préchargement d'instruction''' charge des instructions dans le cache L1 d’instruction. Les instructions proviennent du cache L2, qui mémorise aussi bien données qu'instructions. Aussi, nous parlerons de '''préchargement L2-L1i''', sous entendu : du cache L2 vers le cache L1 d'instruction.
Pour comprendre comment s'effectue le préchargement L2-L1i, rappelons que l'unité de chargement émet des adresses consommées par le cache d'instruction. Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Idem en cas de blocage du pipeline au-delà du cache, par exemple en cas de bulle de pipeline prolongée. Et c'est lors de ces blocages que le préchargement d'instruction se met en place. Lors de ces blocages, une unité de préchargement envoie des lectures au cache L2, pour précharger les instructions adéquates. Elle prédit quelles sont les instructions futures soit en se basant sur la prédiction de branchements, soit en regardant le contenu de la FTQ, soit autrement.
[[File:Préchargement d'instructions.png|centre|vignette|upright=2.5|Préchargement d'instructions]]
L'unité de préchargement peut utiliser n'importe quel algorithme de préchargement : du préchargement séquentiel, du ''target-line prefetching'', ou du préchargement du mauvais chemin.
* Avec le préchargement séquentiel, le préchargement charge la ligne de cache suivante à la dernière ligne de cache accédée.
* Le ''target-line prefetching'' s'enclenche en présence de branchements : l'unité de prédiction de branchement donne l'adresse de destination d'un branchement, et c'est la ligne de cache de la destination qui est chargée.
* Avec le préchargement du mauvais chemin, deux lignes de cache sont préchargées : celle avec ''target-line prefetching'', celle avec préchargement séquentiel. Elle n'a de sens que pour les branchements conditionnels.
Le préchargement séquentiel est facile à implémenter, car il n'a pas à utiliser la prédiction de branchement. Il a juste à analyser les derniers accès mémoire du cache d'instruction. Il n'a pas de différence avec un ''prefetcher'' séquentiel, tel qu'on l'a vu dans le chapitre sur le préchargement. Les deux autres formes de préchargement sont elles bien plus compliquées.
===L'anticipation du ''program counter''===
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
===Le ''Fetch Directed Instruction Prefetching''===
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=Les pipelines fixes
| nextText=Les pipelines fixes
}}
</noinclude>
{{AutoCat}}
9v406e6e5zhqp14o3yyj1bqvyh4h9pe
774929
774874
2026-10-11T19:22:59Z
Mewtow
31375
/* Le Fetch Directed Instruction Prefetching */
774929
wikitext
text/x-wiki
Les processeurs avec un pipeline sont découpés en deux sections : un ''front-end'' qui charge et décode les instructions, et un ou plusieurs ''back-end'' qui exécutent les instructions. L'exécution des micro-opérations est optimisée par des techniques que nous verrons dans la suite du cours : exécution dans le désordre, superscalarité, renommage de registre. Mais un point important est que le ''front-end'' a des optimisations dédiées. La prédiction de branchement est l'une de ces optimisation, mais elle n'est pas la seule. Ce chapitre aborde les optimisations autres que la prédiction de branchement. Nous allons voir le découplage des étages du ''front-end'', le pré-décodage et quelques améliorations liées indirectement à la prédiction de branchement.
==Le prédécodage d'instructions==
La présence d'un cache d'instruction permet l'implémentation de certaines optimisations, dont la plus connue est la technique dite du '''prédécodage'''. Avec elle, lorsque les instructions sont chargées dans le cache d'instruction, elles sont partiellement décodées, grâce à un circuit séparé de l'unité de décodage d'instruction. Le décodage de l'instruction proprement dit est plus court, car une partie du travail est faite en avance, on gagne quelques cycles.
[[File:Prédécodage des instructions dans le cache L1.png|centre|vignette|upright=2.5|Prédécodage des instructions dans le cache L1]]
Le prédécodage est particulièrement utile avec des instructions de taille variable : il permet de pré-déterminer où commencent/terminent les instructions dans une ligne de cache, indiquer leur taille, etc. Autre possibilité, le prédécodage peut indiquer s'il y a des branchements dans une ligne de cache et où ils se trouvent, ce qui est très utile pour la prédiction de branchement.
Pour chaque ligne de cache, le décodage partiel fournit des informations utiles au décodeur d'instruction. Les informations pré-décodées sont soit intégrée dans la ligne de cache, soit mémorisées dans une banque séparée. En clair : une partie de la capacité totale du cache d'instruction est utilisée pour les informations de pré-décodage. Le prédécodage est donc un compromis : un cache d'instruction de plus faible capacité, mais un décodage plus simple.
Le pré-décodage est surtout utile pour les instructions qui sont ré-exécutées souvent. Pour les instructions exécutées une seule fois, le gain en performance dépend de l'efficacité du préchargement et d'autres contraintes, mais ce qui est gagné lors du décodage est souvent partiellement perdu lors du prédécodage. Par contre, si une instruction est exécutée plusieurs fois, le pré-décodage est fait une seule fois, alors qu'on a un gain à chaque ré-exécution de l'instruction.
===Les sélecteurs de branchement intégrés au cache L1===
Le pré-décodage peut être utilisé afin de faciliter le travail de la prédiction de branchement. L'idée est d'incorporer une partie de la prédiction de branchement dans le cache L1 d'instruction. Une ligne de cache mémorise alors des informations de prédiction de branchement dans ses bits de contrôle. Les informations en question peuvent être des adresses de destination, ou simplement de quoi déterminer si le branchement est pris ou non. Il s'agit d'une démarche inverse à celle de la ''Fetch Target Queue'', qui découple l'unité de prédiction de branchement du cache, en insérant une mémoire FIFO entre les deux. Là, la démarche est au contraire de fusionner partiellement cache d'instruction et unité de prédiction de branchement.
Les premiers processeurs AMD utilisaient cette technique, au moins dans les grandes lignes. Une ligne de cache contient potentiellement plusieurs branchements, dont la position est identifiée par le prédécodage. Pour chaque octet, la ligne de cache associe un bit de contrôle qui indique si un branchement démarre à cet octet, si c'est le premier octet d'un branchement. Le prédécodage peut identifier entre un et plusieurs branchement par ligne de cache, il y a une limite. Le prédécodage n'identifie typiquement que les 3 à 5 premiers branchements, les suivants sont ignorés, faute de place dans les bits de contrôle.
Prenons par exemple une ligne de cache de 8 octets, dans laquelle on a 2 branchements de 2 octets chacun.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Branch 1 || bgcolor="#FFFF00" | Branch 1 || Instruction || bgcolor="#FFFF00" | Branch 2 || bgcolor="#FFFF00" | Branch 2 || Instruction || Instruction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 0 || 1 || 0 || 0 || 1 || 0 || 0 || 0
|}
Il est possible d'améliorer le tout en précisant quel est le type du branchement. Par exemple, on peut distinguer les branchements inconditionnel et conditionnels, ou encore les instruction de retour de fonction. L'intérêt n'est pas évident, mais c'est lié au fait que les branchements inconditionnels sont toujours pris, et que les retour de fonction ont une adresse de destination qui est prédite par une unité de branchement séparée, le ''return adress predictor'', pas par un BTB. Deux bits suffisent pour indiquer : si c'est un branchement conditionnel, inconditionnel, un retour de fonction, ou une instruction qui n'est pas un branchement.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Saut inconditionnel || bgcolor="#FFFF00" | Saut inconditionnel || Instruction || bgcolor="#A00000" | Branch cond || bgcolor="#A00000" | Branch cond || Instruction || bgcolor="#F0F000" | Retour de fonction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 00 || 01 || 00 || 00 || 10 || 00 || 00 || 11
|}
L'idée est alors d'ajouter, pour chaque branchement détecté, un '''sélecteur de branchement''' qui indique si le branchement est pris ou non. En clair, des informations de prédiction de branchement sont ajoutés à chaque octet de position. Intuitivement, on se dit qu'il y a seulement un bit par branchement, qui indique si le branchement est pris ou non.
Les prédictions peuvent venir soit de l'unité de prédiction de branchement, soit provenir du prédécodage. Le prédécodage peut faire de la prédiction statique. Elle peut notamment détecter les branchements inconditionnels et les marquer comme pris. Elle peut aussi détecter les branchements conditionnels et le marquer comme non-pris par défaut. L'unité de prédiction de branchement met à jour les sélecteurs de branchements si besoin, pour les branchements conditionnels.
===L'incorporation du ''Branch Target Buffer'' dans le cache d'instruction===
Une première optimisation permet de se passer de ''Branch Target Buffer''. Pour rappel, celui-ci est un cache qui mémorise, pour chaque branchement, quelle est son adresse de destination. Il peut contenir d'autres informations de prédiction, mais laissons-les de côté pour le moment.
L'idée est de déplacer les adresse de destination des branchements dans le cache d'instruction, dans les lignes de cache. Si une ligne de cache contient un branchement, elle mémorise l'adresse de destination de ce branchement, en plus des bits de pré-décodage. En général, les processeurs ne supportent qu'une seule adresse de destination. Si il y a plusieurs branchements dans une ligne de cache, c'est l'adresse de destination du premier branchement pris dans cette ligne de cache qui est mémorisée. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela.
Il faut cependant remarquer qu'à ce petit jeu, les instructions de retour de fonction sont à part. Leur adresse de destination est souvent donnée par une unité de branchement séparée, le ''return adress predictor'', séparée du ''Branch Target Buffer''. Leurs adresses de destination n'ont pas forcément besoin d'être mémorisées dans les lignes de cache.
La technique décrite ici est simple à comprendre. Cependant, les processeurs AMD anciens, d'architecture K6 à K10 n'utilisaient pas cette méthode, mais une variante plus complexe, capable de prédire jusqu'à deux adresses de destination par branchement. A partir de l'architecture K6, le prédécodage déterminait la position des branchements dans les lignes de cache, dans une limite de 4 branchements par ligne de cache. Pour chaque branchement, la ligne de cache mémorisait un sélecteur de branchement, codé sur 2 bits. La valeur des bits indiquait que le branchement n'est pas pris si elle vaut 00, que c'est une instruction de retour de fonction si elle vaut 01, qu'il faut brancher à l'adresse de destination X si elle vaut 10, qu'il faut brancher à l'adresse de destination X si elle vaut 11. Les adresses de destination sont quand à elles mémorisées dans un cache séparé, appelé le ''Branch Target Cache''. Le mécanisme pour adresser ce cache à partir du cache d'instruction n'est pas très détaillé dans la documentation d'AMD.
===Les avantages et inconvénients===
L'avantage de faire ainsi est que la prédiction de branchement est plus rapide. Lire une instruction depuis le cache renvoie non seulement l'instruction lue, mais aussi des informations de prédiction de branchement. L'unité de prédiction de branchement peut alors utiliser ces informations au cycle suivant pour savoir quelle est l'instruction suivante à charger.
Un défaut de cette approche est que si le branchement à prédire n'est pas dans le L1 d'instruction, aucune prédiction de branchement ne peut être faite et le préchargement ne peut pas fonctionner. C'est une limitation que n'ont pas les BTB découplées du cache L1 : elles peuvent prédire les adresses de destination et la direction d'un branchement, tant que l'entrée associée est dans le BTB. Et l'entrée peut être conservée, même si l'instruction en question a quitté le cache L1 et qu'elle est dans le L2, le L3 ou même en mémoire RAM. Les prédictions peuvent même servir à précharger les instructions utiles.
Sur l'Itanium et l'AMD Opteron, une optimisation assez intéressante permet de conserver les prédictions de branchement lorsque l'un branchement est évincé du cache L1 et se retrouve dans le cache L2. En théorie, les informations de prédiction, présentes dans la ligne de cache, sont perdues lorsque le branchement est évincé. Mais ces processeurs conservent ces prédictions dans un cache séparé, appelé le '''''L2 Branch Cache'''''.
==Le découplage du ''front-end''==
Le séquenceur d'un processeur contient une unité de chargement, un décodeur et un chemin de données. Avec un pipeline, le couplage de ces structures fait que si l'une d'entre elle prend plus d'un cycle pour faire son travail, les étages précédents et/ou suivants sont stoppés. Par exemple, lors d'un défaut dans le cache d'instruction, l'ensemble stoppe. De même, si jamais le décodeur doit décoder une instruction via le micro-code, cela prend plusieurs cycles : l'unité de chargement et le cache d'instruction sont inutilisés. Même chose si jamais une instruction multicycle s’exécute dans le pipeline : cela bloque toutes les étapes précédentes.
Mais il s'agit là d'un défaut inhérent aux pipelines basiques, qui relient chaque étage avec des registres. Avec un peu d'astuce, il est possible que certains étages prennent de l'avance même si l'étage suivant est bloqué. Par exemple, si le décodeur bloque le pipeline en utilisant son micro-code pendant 4-5 cycles, l'unité de chargement peut en théorie précharger à l'avance les instructions suivantes, et les mettre en attente. Ou encore, en cas de bulle de pipeline (''pipeline stall''), le décodeur peut décoder en avance des instructions et les mettre en attente tant que la bulle de pipeline est en cours.
Dans les deux cas, une unité prend de l'avance et met en attente ses résultats tant que l'étage suivant est occupé. La mise en attente est réalisée en remplaçant/complémentant les registres du pipeline avec des mémoires FIFOs. Elles remplacent ou complémentant les registres de pipeline, les deux sont possibles. Si elles remplacent ces registres, si aucune mise en attente n'est requise, elles fonctionnent comme un registre de pipeline.
===La file d'instruction===
[[File:File de micro-opérations.png|vignette|upright=1|File d'instructions]]
Les processeurs modernes intègrent une '''file d'instruction''', à savoir une mémoire FIFO placée entre le cache d'instruction et le décodeur d'instruction. Les instructions chargées par l'étape de chargement soient accumulées dans la file d'instructions et sont décodées quand l'unité de décodage est prête. La file d'attente permet de charger des instructions à l'avance, permettant ainsi de masquer certains accès au cache ou à la mémoire assez longs.
L'idée est que les instructions s'accumulent dans la file d'instruction si le processeur exécute les instructions moins vite qu'il ne les charge. C'est généralement signe qu'il effectue une instruction multicycle et/ou qu'il effectue un accès à la mémoire. À l'inverse, la file d'attente se vide quand le processeur exécute les instructions plus vite qu'il n'en charge. C'est généralement signe qu'un défaut de cache d'instruction est en cours. La présence d'une file d'attente fait que la première situation est compensée lors de la seconde. Les temps d'attentes liées aux instructions multicycles permettent de remplir la file d'attente, qui est ensuite vidée en cas de défaut de cache. Le processeur exécute en permanence des instructions, sans interruption. Alors que sans file d'attente, les défauts de cache entraineront des temps d'attente où le processeur s’exécuterait rien.
La seule limite de cette optimisation est l'influence des branchements. Lorsqu'un branchement est décodé, ce tampon d’instructions est totalement vidé de son contenu. Ce n'est ni plus ni moins ce que faisait la ''prefetch input queue'' des anciens processeurs Intel, dont nous avions parlé dans le chapitre sur l'unité de chargement et le séquenceur.
===La file de micro-opérations===
[[File:File de micro-opération.png|vignette|upright=1|File d'instruction]]
L'optimisation précédente peut aussi s'appliquer entre le décodeur d'instruction et le chemin de données. Pour cela, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction. Elle mémorise les micro-opérations émises par le décodeur et les met en attente tant que le reste du pipeline n'est pas prêt. Nous l'appellerons la '''file de micro-opérations''', mais elle porte de nombreux noms et la terminologie des différents fabricants est assez confuse. Le schéma ci-contre indique que la file de micro-opérations est située en sortie de l’unité de décodage, avant l'unité d'émission et avant l'unité de renommage de registres (que nous aborderons dans quelques chapitres).
La file de micro-opérations permet aux décodeurs de faire leur travail même si le reste du pipeline n'est pas prêt. Par exemple, imaginons que le processeur ne peut pas émettre de nouvelle instruction, soit car toutes les ALUs sont occupées, soit car il y a un accès mémoire qui bloque le pipeline, peu importe. Sans file de micro-opérations, tout ce qui précède l'unité d'émission devrait être totalement bloqué tant que l'instruction ne peut pas être émise. Mais avec une file de micro-opérations, le pipeline peut charger et décoder des instructions, il a juste à accumuler les instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions décodées quand des bulles de pipeline sont émises.
Et à l'inverse, elle permet d'émettre des instructions quand les unités de décodage/chargement sont bloquées. Le cas classique est celui d'un défaut de cache dans le cache d'instruction. Pendant un défaut de cache, aucune instruction n'est chargée ni décodées durant quelques cycles. Sans file de micro-opérations, le processeur ne peut plus rien faire durant quelques cycles. Mais avec une file de micro-opérations, il se rattrape en émettant les instructions en attente dans la file de micro-opérations, s'il y en a. En clair, si l'unité d'émission a mis en attente des instructions, le processeur se rattrape au prochain défaut de cache d'instruction.
Une autre situation où le décodeur bloque est le cas où certaines instructions mettent du temps à être décodées. C'est notamment le cas de certaines instructions complexes, dont le décodage prend facilement 2 à 3 cycles d'horloge, voire plus. Le pire est le décodage des instructions microcodées, qui peut demander plusieurs cycles. Or, le pipeline demande qu'on décode une instruction par cycle pour éviter de bloquer le pipeline. Mais ce temps de décodage peut être masqué si des micro-opérations sont en attente dans la file, elles sont exécutées pendant le décodage long.
===Le ''Loop Stream Detector''===
Les boucles sont une opportunité d'optimisation très intéressante sur les CPU avec une file de micro-opérations. L'idée est que lors d'une boucle, des instructions sont chargées, décodées et exécutées plusieurs fois de suite. Mais à chaque répétition d'une instruction, le chargement et le décodage donnent toujours le même résultat, seule l'exécution n'est pas la même (les registres renommés sont aussi différents, mais nous verrons cela dans plusieurs chapitres). L'idée est simplement de mémoriser les N dernières instructions décodées et de les ré-exécuter si besoin. Ainsi, on évite de charger/décoder une même instruction machine plusieurs fois, mais de réutiliser les micro-opérations déjà décodées.
L'implémentation la plus simple utiliser la file de micro-opérations comme une sorte de pseudo-cache FIFO. La file de micro-opérations ne supprime pas les micro-opérations une fois qu'elles sont émises. Elle mémorise là où se trouve la dernière micro-opération émise, mais conserve celles qui ont déjà été émises. Un circuit annexe, appelé le '''''Loop Stream Detector''''' (LSD), détecte les boucles dans la file de micro-opérations et optimise leur exécution. Si une boucle adéquate est détectée par le ''Loop Stream Detector'', les micro-opérations de la boucle sont lues dans la file de micro-opération et sont injectées directement dans la suite du pipeline. De plus, les unités de chargement et de décodage sont désactivées pendant l’exécution de la boucle, ce qui réduit la consommation d'énergie du CPU.
L'optimisation accélère les petites boucles, qui tiennent toutes entières dans la file de micro-opérations, et sous condition qu'elles s'exécutent de la même manière à chaque exécution. De telles boucles exécutent une suite de N instructions, qui reste identique à chaque itération de la boucle. Le cas le plus simple est celui d'une boucle dans laquelle il n'y a pas de branchements. Pour les boucles normales, le processeur reprend une exécution normale quand on quitte la boucle ou quand son exécution change, par exemple quand un if...else, un return ou tout autre changement de flot de contrôle a lieu. Vu que toutes ces situations impliquent un branchement qui n'a pas été pris comme avant, le processeur n'utilise plus le ''Loop Stream Detector'' en cas de mauvaise prédiction de branchement.
Le LSD vise surtout à désactiver les décodeurs et l'unité de chargement lors de l'exécution d'une boucle. La désactivation peut être du ''clock gating'', voire du ''power gating'', être partielle ou totale. Dans le pire des cas, les unités de chargement peuvent continuer à charger des instructions en avance dans une file d'instruction, mais les décodeurs peuvent être désactivés. Dans le meilleur des cas, la totalité de ce qui précède la file de micro-opération est désactivé tant que la boucle s’exécute normalement. Y compris le cache de micro-opération.
[[File:Loop Stream Detector.png|centre|vignette|upright=2|Loop Stream Detector]]
Évidemment, la taille des boucles optimisées ainsi est limitée par la taille de la file de micro-opération, ce qui fait que l'optimisation ne fonctionne que pour des boucles de petite taille. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 micro-opérations. De plus, toute la file de micro-opération n'est pas gérée par le ''loop stream detector''. Par exemple, les processeurs avec une file de micro-opération de 64 micro-opération peuvent gérer des boucles de maximum 32 à 40 micro-opérations.
Mais les contraintes principales portent sur la détection des boucles. Le ''Loop Stream Detector'' ne peut pas détecter toutes les boucles qui existent, et certaines boucles ne sont pas détectées. Par exemple, le ''Loop Stream Detector' ne peut pas détecter les boucles si un appel de fonction a lieu dans la boucle. Il y a aussi des contraintes quant au nombre de branchements à l'intérieur de la boucle et le nombre d'accès mémoire.
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les micro-architectures Zen 4 mais il a disparu sur la micro-architecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la micro-architecture Zen 4 d'AMD ou les CPU de micro-architecture Skylake et Kaby Lake d'Intel. Pour la micro-architecture Skylake, les raisons officielles pour cette désactivation sont un bug lié à l'interaction avec l'''hyperthreading''. Il est vraisemblable que des bugs ou des problèmes de sécurité aient amené à la désactivation sur les autres architectures.
===Le cache de micro-opérations===
Le '''cache de micro-opérations''' a le même but que le ''Loop Stream Detector'', à savoir optimiser l'exécution des boucles. La différence avec le ''Loop Stream Detector'' est qu'il y a un cache séparé de la file de micro-opérations, qui mémorise des micro-opérations décodées, dans le cas où elles soient réutilisées par la suite. La première itération d'une boucle accumule les instructions décodées dans le cache de micro-opérations, les itérations suivantes de la boucle lisent les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois. Sur de nombreux processeurs, le cache de micro-opération est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ainsi, seules les micro-opérations émises sont copiées dans ce cache.
[[File:File de micro-opérations et cache de micro-ops.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
Les avantages sont les mêmes qu'avec un ''Loop Stream Detector'' : une consommation énergétique réduite, des performances légèrement améliorées. Le décodeur et l'unité de chargement sont inutiles en cas de succès dans le cache de micro-opération, ce qui fait qu'ils sont désactivés, éteints, ou du moins subissent un ''clock-gating'' temporaire. Ils ne consomment pas d'énergie, seul le cache de micro-opération utilise de l'électricité. L'avantage en termes de performance est plus faible, assez variable suivant la situation, mais aussi bien le cache de micro-opérations que le LSD ne font pas de mal.
Une différence avec le cache de micro-opération est qu'une boucle doit s’exécuter à l'identique avec un ''Loop Stream Detector'', pas avec un cache de micro-opérations. Prenons l'exemple d'une boucle contenant quelques instructions suivies par un IF...ELSE. Il arrive qu'une itération de la boucle exécute le IF, alors que d'autres exécutent le ELSE. Dans ce cas, le ''Loop Stream Detector'' ne sera pas activé, car la boucle ne s’exécute pas pareil d'une itération à l'autre. Par contre, un cache de macro/micro-opération mémorisera les micro-opérations du IF et du ELSE et les fournira selon les besoins. La raison est que le cache de micro-opérations a une politique de remplacement des lignes de cache plus complexe que le FIFO, typiquement une politique LRU ou LFU approximée. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', pour un cout en transistor plus élevé.
Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. L'accès au cache de micro-opération se fait lors de l'étape de chargement. Le cache de micro-opérations est adressé en envoyant le ''program counter'' sur son entrée d'adresse, en parallèle du cache d'instruction. En clair, il y a une voie qui regroupe cache d'instruction, file d'instruction et décodeur, et une seconde voie qui se résume au cache de micro-opération. Les deux voies sont accédées en parallèle. En cas de succès dans le cache de micro-opération, les micro-opérations adéquates sont lues directement depuis le cache de micro-opération.
Le cache de micro-opération associe, pour chaque instruction machine, une ou plusieurs micro-opérations. Avec l'implémentation la plus simple, une ligne de cache est associée à une instruction machine. Par exemple, sur les processeurs Intel de micro-architecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. Une instruction devait tenir toute entière dans une ligne de cache, ce qui fait que les instructions décodées en plus de 6 micro-opérations ne pouvaient pas rentrer dans ce cache.
Il existe deux méthodes différentes pour encoder les micro-opérations dans le cache de micro-opérations. La première est la plus intuitive : on mémorise les micro-opérations dans la ligne de cache, directement. Elle est utilisée sur les processeurs AMD, et sans doute sur les processeurs Intel récents. Mais les anciens processeurs Intel, comme ceux des architectures Sandy Bridge et Netburst, utilisent une autre méthode. Une ligne de cache mémorise non pas les micro-opération directement, mais un pointeur vers le ''control store'', qui indique à quelle adresse dans le micro-code se situe la micro-opération. La micro-opération est donc lue depuis le micro-code lors de son envoi au chemin de données, lors de son émission.
Il faut noter que pour des raisons de performance, le cache de micro-opérations est virtuellement tagué, ce qui fait qu'il est invalidé en cas de changement de programme. Sur l'architecture Sandy Bridge, il est carrément inclus dans le cache L1, les deux sont des caches inclusifs l'un avec l'autre. Les premières implémentations étaient très limitées. Les micro-opérations devaient être séquentielles dans le code, le cache était consulté seulement après un branchement et non à chaque instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Le cache de micro-opérations et le ''Loop Stream Detector'' font la même chose, mais certains processeurs implémentaient les deux. L'avantage est que le cache de micro-opération peut être désactivé si jamais le LSD détecte une boucle dans la file d'instruction, ce qui réduit encore plus la consommation énergétique. En pratique, l'impact sur la consommation énergétique est très difficile à mesurer, mais il rajoute de la complexité pour la conception du processeur.
[[File:File de micro-opérations et cache de micro-ops - Copie.png|centre|vignette|upright=2.5|File de micro-opérations et cache de micro-ops - Copie]]
===La ''Fetch Target Queue''===
Nous venons de voir qu'il est possible de découpler les étages de chargement, décodage et le chemin de données, en insérant des mémoires FIFOs dans le pipeline. Il en est de même avec l'unité de chargement elle-même. Elle est composée de deux circuits qu'on peut découpler : le cache d'instruction, l'unité de prédiction de branchement, et l'unité de calcul d'adresse. L'unité de calcul d'adresse regroupe : le ''program counter'', de quoi l'incrémenter, les MUX associés pour gérer les branchements.
Les processeurs modernes découplent l'unité de calcul d'adresse du cache d'instruction, en insérant une mémoire FIFO entre les deux. Les premiers articles scientifiques, qui ont proposé cette solution, l'ont appelée la '''''Fetch Target Queue''''', abréviée FTQ. Elle accumule les adresses à lire dans le cache d'instruction, peu importe que ces adresses viennent du ''program counter'' ou de l'unité de prédiction de branchement.
[[File:Fetch target queue.png|centre|vignette|upright=2.5|Fetch target queue]]
Elle se remplit quand le cache d'instruction est bloqué, soit à cause d'un défaut de cache, soit à cause d'un pipeline bloqué en amont de l'unité de chargement. Par exemple, si le cache d'instruction est bloqué par un défaut de cache, l'unité de prédiction de branchement peut accumuler des prédictions à l'avance dans la FTQ, qui sont ensuite consommées par le cache d'instruction une fois qu'il est redevenu disponible. De même, si l'unité de prédiction de branchement est bloquée par un évènement quelconque, le cache d'instruction peut consommer les prédictions faites à l'avance.
Une utilisation assez originale de la FTQ s'est vu sur les processeurs AMD d'architectures bulldozer. Sur cette architecture, les cœurs étaient regroupés par paquets de deux, et les deux cœurs partageaient certains circuits. Notamment, l'unité de prédiction de branchement était partagée entre les deux cœurs ! Pourtant, chaque cœur disposait de sa propre FTQ !
==Le ''Branch Folding'' et la file d'instruction==
Une file d'instruction basique précharge les instructions qui suivent la dernière instruction chargée. Mais une telle file d'instruction gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées. De plus, les instructions préchargées par erreurs doivent être supprimées pour éviter qu'elles soient décodées et exécutées, ce qui fait que la file d’instruction doit être invalidée.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Heureusement, il existe des techniques qui visent à réduire l'impact des branchements sur la file d'instruction. L'idée est d'exécuter les branchements à l'avance, avant même leur décodage ! L'optimisation en question est appelée le '''''Branch Folding''''', et a été utilisé sur les processeurs PowerPC, pour ne citer qu'eux.
Le ''branch folding'' a une ressemblance très forte avec le préchargement appliqué aux instructions. Il faut dire qu'une file d'instruction a pour rôle de charger des instructions ''à l'avance''. La différence est la cible : dans la file d'instruction pour le ''branch folding'', dans le cache pour le préchargement d'instruction. Pour faire la distinction avec les autres formes de préchargement, nous parlerons de '''préchargement interne'''.
Les algorithmes utilisés pour le préchargement interne sont cependant les mêmes que pour le préchargement d'instruction. Aussi, nous allons les voir en même temps. Le préchargement séquentiel a pour équivalent... la file d'instruction elle-même. Du moins, dans son fonctionnement normal. Voyons maintenant les autres algorithmes.
===Le ''target line prefetching''===
Une version améliorée prend en charge les branchements d'une manière un peu plus naturelle. Elle agit comme le préchargement séquentiel, sauf quand elle rencontre un branchement inconditionnel. Dans ce cas, elle précharge les instructions à partir de l'adresse de destination du branchement. Il s'agit du '''''target line prefetching'''''.
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
La technique demande de détecter les branchements inconditionnels. Pour cela, une unité spécialisée scanne la file d'instruction, pour y trouver les branchements. L'unité en question s'appelle l''''unité de ''branch folding'''''. Si un branchement inconditionnel est détecté dedans, elle utilise le ''target line prefetching''. L'adresse de destination est extraite du branchement inconditionnel, et le préchargement continue à partir de cette adresse. Pour cela, le ''program counter'' est altéré en avance. De plus, le branchement est remplacé par l'instruction de destination du branchement. Les instructions qui "suivent" le branchement sont annulées avant, histoire d'éliminer les instructions chargées à tord.
[[File:Branch folding.png|centre|vignette|upright=2|Branch folding.]]
Maintenant, qu'en est-il des branchements conditionnels ? La réponse de si la prédiction de branchement est utilisée ou pas. Sans prédiction de branchement, les branchements conditionnels sont considérés comme non-pris. Avec prédiction de branchement, tout dépend de si le branchement est prédit comme pris ou non. Si le branchement est considéré comme pris, il est traité comme un branchement inconditionnel : il est remplacé par l'instruction de destination et le ''program counter'' est altéré. Si le branchement est non-pris, il suffit de ne rien faire et de continuer le préchargement séquentiel.
Notez qu'il faut que l'adresse de destination soit connue à l'avance, ou du moins puisse être calculée facilement (branchements relatifs). L'unité de branchement peut contenir un additionneur afin de calculer l'adresse de destination d'un branchement relatif.
===Le préchargement du mauvais chemin===
Le ''target line prefetching'' peut être associé avec le préchargement séquentiel, pour gérer les branchements conditionnels au miuex. L'idée est de précharger à la fois les instructions après le branchement et celles à sa destination. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates auront été préchargées. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching''). La technique était implémentée sur le mainframe IBM 370/165 et quelques modèles IBM similaires. Les processeurs PowerPC utilisaient aussi cette technique.
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le préchargement du mauvais chemin demande d'utiliser deux files d'instructions séparées. L'une dans laquelle on précharge de manière séquentielle, l'autre utilisant la ''target line prefetching''. Le choix de la bonne file se fait avec un multiplexeur. Une fois le branchement conditionnel exécuté, on sait s'il est pris ou non, le contenu de la file adéquate est conservé, l'autre est intégralement invalidée.
[[File:Branch folding avec prédiction du mauvais chemin.png|centre|vignette|upright=2|''Branch folding'' avec prédiction du mauvais chemin.]]
Le préchargement du mauvais chemin réduit un peu la pénalité en cas de mauvaise prédiction de branchement, en économisant quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque charger l'instruction de destination déclenche un défaut de cache. Le préchargement déclenche alors le défaut de cache en avance de quelques cycles, qui peuvent être traité avant même que la mauvaise prédiction de branchement soit détectée.
Elle a le défaut de bouffer beaucoup de débit binaire, au niveau du cache d'instruction. Elle fonctionne mieux si l'on peut précharger deux instructions par cycle, une par chemin. Et cela demande un cache d'instruction double port, dont le cout en circuit n'est pas négligeable. Et le gain en performance est assez faible.
Les premiers processeurs PowerPC utilisaient cette optimisation. Ils avaient une file d'instruction de 8 instructions, et l'unité de branchement scannait les 4 premières instructions. L'unité de ''branch folding'' contenait les registres nécessaires pour gérer les branchements. Elle contenait précisément le ''link register'' pour des appels de procédures, le ''Count Target Register'' et le ''Count Register'' utilisés pour les boucles. Elle contient aussi un additionneur pour calculer les adresses des branchements relatifs.
L'unité de ''branch folding'' identifiait les branchements, sans décoder les instructions en attente. A la place, les instructions étaient pré-décodées dans le cache d'instruction, qui identifiait la position des branchement. Les informations de prédécodage étaient utilisées pour remplir la file d'instruction. Chaque instruction dans la file d'instruction est associée à un bit qui indique si c'est un branchement ou non. L'unité de branchement anticipé peut alors détecter les branchements avec un simple encodeur à priorité.
[[File:Branch folding des CPU PowerPC.png|centre|vignette|upright=2|Branch folding des CPU PowerPC - les bits de prédécodage sont en couleur. Les branchement sont en rouge, les autres instructions en jaune.]]
===Le préchargement du mauvais chemin avec plusieurs branchements consécutifs===
Un défaut du préchargement du mauvais chemin est qu'elle gère mal le cas où deux branchements sont assez proches dans le code machine. Un branchement scinde le flux d'instructions : un où le branchement est pris, un autre où il ne l'est pas. Chacun de ces flux peut lui-même contenir un branchement, et se scinder lui aussi. Et ainsi de suite. Et le processeur doit gérer cette situation en termes de préchargement.
[[File:Exécution stricte 04.png|centre|vignette|upright=2|Exécution stricte]]
Plusieurs solutions existent. La première est de ne pas utiliser le ''target line prefetching'' au-delà de deux branchements. La méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Une solution un peu moins intuitive utilise le préchargement du mauvais chemin pour le premier branchement, mais bascule sur le préchargement séquentiel pour les branchements suivants. Cette solution est celle qui est systématiquement implémentée, car elle est simple à implémenter. Une dernière possibilité est d'utiliser la prédiction de branchement au-delà du premier branchement, afin de poursuivre le chargement de manière spéculative.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
Une autre possibilité consiste à utiliser le préchargement du mauvais chemin pour le second branchement, ainsi que ceux des flux extérieurs. Cette dernière est impraticable car elle demande des caches avec un grand nombre de ports et la présence de plusieurs files d'instructions, qui sont utilisées assez rarement. Et quitte à gaspiller autant de transistors, autant les utiliser décoder/exécuter directement les instructions, au lieu de les accumuler dans des files d'instruction. C'est ce qu'on appelle l''''exécution stricte''' (''eager execution'').
Quelques papiers de recherche ont étudié l'idée, mais ses défauts font qu'elle n'a jamais été utilisée dans un processeur en dehors de prototypes de recherche. Le gros problème de l'exécution stricte est qu'on est limité par le nombre d'unités de calculs, de registres, etc. Autant ce serait une technique idéale sur des processeurs avec un nombre illimité de registres ou d'unités de calcul, autant ce n'est pas le cas dans le monde réel. Au bout d'un certain nombre d’embranchements, le processeur finit par ne plus pouvoir poursuivre l’exécution, par manque de ressources matérielles et doit soit stopper, soit recourir à la prédiction de branchement.
==Le préchargement d'instructions==
Dans le chapitre sur le préchargement, nous avons volontairement mis de côté les techniques de ''préchargement d'instruction'', car ce n'était pas le moment d'en parler. Mais le moment est venu d'aborder le préchargement pour les instructions, d’où cette section. La section précédente parlait d'une forme de préchargement qui n'en est pas vraiment, car elle n'implique pas de mémoire cache. Par contre, le préchargement que nous allons voir le fait. Le '''préchargement d'instruction''' charge des instructions dans le cache L1 d’instruction. Les instructions proviennent du cache L2, qui mémorise aussi bien données qu'instructions. Aussi, nous parlerons de '''préchargement L2-L1i''', sous entendu : du cache L2 vers le cache L1 d'instruction.
Pour comprendre comment s'effectue le préchargement L2-L1i, rappelons que l'unité de chargement émet des adresses consommées par le cache d'instruction. Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Idem en cas de blocage du pipeline au-delà du cache, par exemple en cas de bulle de pipeline prolongée. Et c'est lors de ces blocages que le préchargement d'instruction se met en place. Lors de ces blocages, une unité de préchargement envoie des lectures au cache L2, pour précharger les instructions adéquates. Elle prédit quelles sont les instructions futures soit en se basant sur la prédiction de branchements, soit en regardant le contenu de la FTQ, soit autrement.
[[File:Préchargement d'instructions.png|centre|vignette|upright=2.5|Préchargement d'instructions]]
L'unité de préchargement peut utiliser n'importe quel algorithme de préchargement : du préchargement séquentiel, du ''target-line prefetching'', ou du préchargement du mauvais chemin.
* Avec le préchargement séquentiel, le préchargement charge la ligne de cache suivante à la dernière ligne de cache accédée.
* Le ''target-line prefetching'' s'enclenche en présence de branchements : l'unité de prédiction de branchement donne l'adresse de destination d'un branchement, et c'est la ligne de cache de la destination qui est chargée.
* Avec le préchargement du mauvais chemin, deux lignes de cache sont préchargées : celle avec ''target-line prefetching'', celle avec préchargement séquentiel. Elle n'a de sens que pour les branchements conditionnels.
Le préchargement séquentiel est facile à implémenter, car il n'a pas à utiliser la prédiction de branchement. Il a juste à analyser les derniers accès mémoire du cache d'instruction. Il n'a pas de différence avec un ''prefetcher'' séquentiel, tel qu'on l'a vu dans le chapitre sur le préchargement. Les deux autres formes de préchargement sont elles bien plus compliquées.
===L'anticipation du ''program counter''===
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
===Le ''Fetch Directed Instruction Prefetching''===
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
{{AutoCat}}
9r0qkmagvny1sq04vi9ohe9xyh42j8f
775007
774929
2026-10-11T23:14:39Z
Mewtow
31375
/* Le Fetch Directed Instruction Prefetching */
775007
wikitext
text/x-wiki
Les processeurs avec un pipeline sont découpés en deux sections : un ''front-end'' qui charge et décode les instructions, et un ou plusieurs ''back-end'' qui exécutent les instructions. L'exécution des micro-opérations est optimisée par des techniques que nous verrons dans la suite du cours : exécution dans le désordre, superscalarité, renommage de registre. Mais un point important est que le ''front-end'' a des optimisations dédiées. La prédiction de branchement est l'une de ces optimisation, mais elle n'est pas la seule. Ce chapitre aborde les optimisations autres que la prédiction de branchement. Nous allons voir le découplage des étages du ''front-end'', le pré-décodage et quelques améliorations liées indirectement à la prédiction de branchement.
==Le prédécodage d'instructions==
La présence d'un cache d'instruction permet l'implémentation de certaines optimisations, dont la plus connue est la technique dite du '''prédécodage'''. Avec elle, lorsque les instructions sont chargées dans le cache d'instruction, elles sont partiellement décodées, grâce à un circuit séparé de l'unité de décodage d'instruction. Le décodage de l'instruction proprement dit est plus court, car une partie du travail est faite en avance, on gagne quelques cycles.
[[File:Prédécodage des instructions dans le cache L1.png|centre|vignette|upright=2.5|Prédécodage des instructions dans le cache L1]]
Le prédécodage est particulièrement utile avec des instructions de taille variable : il permet de pré-déterminer où commencent/terminent les instructions dans une ligne de cache, indiquer leur taille, etc. Autre possibilité, le prédécodage peut indiquer s'il y a des branchements dans une ligne de cache et où ils se trouvent, ce qui est très utile pour la prédiction de branchement.
Pour chaque ligne de cache, le décodage partiel fournit des informations utiles au décodeur d'instruction. Les informations pré-décodées sont soit intégrée dans la ligne de cache, soit mémorisées dans une banque séparée. En clair : une partie de la capacité totale du cache d'instruction est utilisée pour les informations de pré-décodage. Le prédécodage est donc un compromis : un cache d'instruction de plus faible capacité, mais un décodage plus simple.
Le pré-décodage est surtout utile pour les instructions qui sont ré-exécutées souvent. Pour les instructions exécutées une seule fois, le gain en performance dépend de l'efficacité du préchargement et d'autres contraintes, mais ce qui est gagné lors du décodage est souvent partiellement perdu lors du prédécodage. Par contre, si une instruction est exécutée plusieurs fois, le pré-décodage est fait une seule fois, alors qu'on a un gain à chaque ré-exécution de l'instruction.
===Les sélecteurs de branchement intégrés au cache L1===
Le pré-décodage peut être utilisé afin de faciliter le travail de la prédiction de branchement. L'idée est d'incorporer une partie de la prédiction de branchement dans le cache L1 d'instruction. Une ligne de cache mémorise alors des informations de prédiction de branchement dans ses bits de contrôle. Les informations en question peuvent être des adresses de destination, ou simplement de quoi déterminer si le branchement est pris ou non. Il s'agit d'une démarche inverse à celle de la ''Fetch Target Queue'', qui découple l'unité de prédiction de branchement du cache, en insérant une mémoire FIFO entre les deux. Là, la démarche est au contraire de fusionner partiellement cache d'instruction et unité de prédiction de branchement.
Les premiers processeurs AMD utilisaient cette technique, au moins dans les grandes lignes. Une ligne de cache contient potentiellement plusieurs branchements, dont la position est identifiée par le prédécodage. Pour chaque octet, la ligne de cache associe un bit de contrôle qui indique si un branchement démarre à cet octet, si c'est le premier octet d'un branchement. Le prédécodage peut identifier entre un et plusieurs branchement par ligne de cache, il y a une limite. Le prédécodage n'identifie typiquement que les 3 à 5 premiers branchements, les suivants sont ignorés, faute de place dans les bits de contrôle.
Prenons par exemple une ligne de cache de 8 octets, dans laquelle on a 2 branchements de 2 octets chacun.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Branch 1 || bgcolor="#FFFF00" | Branch 1 || Instruction || bgcolor="#FFFF00" | Branch 2 || bgcolor="#FFFF00" | Branch 2 || Instruction || Instruction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 0 || 1 || 0 || 0 || 1 || 0 || 0 || 0
|}
Il est possible d'améliorer le tout en précisant quel est le type du branchement. Par exemple, on peut distinguer les branchements inconditionnel et conditionnels, ou encore les instruction de retour de fonction. L'intérêt n'est pas évident, mais c'est lié au fait que les branchements inconditionnels sont toujours pris, et que les retour de fonction ont une adresse de destination qui est prédite par une unité de branchement séparée, le ''return adress predictor'', pas par un BTB. Deux bits suffisent pour indiquer : si c'est un branchement conditionnel, inconditionnel, un retour de fonction, ou une instruction qui n'est pas un branchement.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Saut inconditionnel || bgcolor="#FFFF00" | Saut inconditionnel || Instruction || bgcolor="#A00000" | Branch cond || bgcolor="#A00000" | Branch cond || Instruction || bgcolor="#F0F000" | Retour de fonction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 00 || 01 || 00 || 00 || 10 || 00 || 00 || 11
|}
L'idée est alors d'ajouter, pour chaque branchement détecté, un '''sélecteur de branchement''' qui indique si le branchement est pris ou non. En clair, des informations de prédiction de branchement sont ajoutés à chaque octet de position. Intuitivement, on se dit qu'il y a seulement un bit par branchement, qui indique si le branchement est pris ou non.
Les prédictions peuvent venir soit de l'unité de prédiction de branchement, soit provenir du prédécodage. Le prédécodage peut faire de la prédiction statique. Elle peut notamment détecter les branchements inconditionnels et les marquer comme pris. Elle peut aussi détecter les branchements conditionnels et le marquer comme non-pris par défaut. L'unité de prédiction de branchement met à jour les sélecteurs de branchements si besoin, pour les branchements conditionnels.
===L'incorporation du ''Branch Target Buffer'' dans le cache d'instruction===
Une première optimisation permet de se passer de ''Branch Target Buffer''. Pour rappel, celui-ci est un cache qui mémorise, pour chaque branchement, quelle est son adresse de destination. Il peut contenir d'autres informations de prédiction, mais laissons-les de côté pour le moment.
L'idée est de déplacer les adresse de destination des branchements dans le cache d'instruction, dans les lignes de cache. Si une ligne de cache contient un branchement, elle mémorise l'adresse de destination de ce branchement, en plus des bits de pré-décodage. En général, les processeurs ne supportent qu'une seule adresse de destination. Si il y a plusieurs branchements dans une ligne de cache, c'est l'adresse de destination du premier branchement pris dans cette ligne de cache qui est mémorisée. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela.
Il faut cependant remarquer qu'à ce petit jeu, les instructions de retour de fonction sont à part. Leur adresse de destination est souvent donnée par une unité de branchement séparée, le ''return adress predictor'', séparée du ''Branch Target Buffer''. Leurs adresses de destination n'ont pas forcément besoin d'être mémorisées dans les lignes de cache.
La technique décrite ici est simple à comprendre. Cependant, les processeurs AMD anciens, d'architecture K6 à K10 n'utilisaient pas cette méthode, mais une variante plus complexe, capable de prédire jusqu'à deux adresses de destination par branchement. A partir de l'architecture K6, le prédécodage déterminait la position des branchements dans les lignes de cache, dans une limite de 4 branchements par ligne de cache. Pour chaque branchement, la ligne de cache mémorisait un sélecteur de branchement, codé sur 2 bits. La valeur des bits indiquait que le branchement n'est pas pris si elle vaut 00, que c'est une instruction de retour de fonction si elle vaut 01, qu'il faut brancher à l'adresse de destination X si elle vaut 10, qu'il faut brancher à l'adresse de destination X si elle vaut 11. Les adresses de destination sont quand à elles mémorisées dans un cache séparé, appelé le ''Branch Target Cache''. Le mécanisme pour adresser ce cache à partir du cache d'instruction n'est pas très détaillé dans la documentation d'AMD.
===Les avantages et inconvénients===
L'avantage de faire ainsi est que la prédiction de branchement est plus rapide. Lire une instruction depuis le cache renvoie non seulement l'instruction lue, mais aussi des informations de prédiction de branchement. L'unité de prédiction de branchement peut alors utiliser ces informations au cycle suivant pour savoir quelle est l'instruction suivante à charger.
Un défaut de cette approche est que si le branchement à prédire n'est pas dans le L1 d'instruction, aucune prédiction de branchement ne peut être faite et le préchargement ne peut pas fonctionner. C'est une limitation que n'ont pas les BTB découplées du cache L1 : elles peuvent prédire les adresses de destination et la direction d'un branchement, tant que l'entrée associée est dans le BTB. Et l'entrée peut être conservée, même si l'instruction en question a quitté le cache L1 et qu'elle est dans le L2, le L3 ou même en mémoire RAM. Les prédictions peuvent même servir à précharger les instructions utiles.
Sur l'Itanium et l'AMD Opteron, une optimisation assez intéressante permet de conserver les prédictions de branchement lorsque l'un branchement est évincé du cache L1 et se retrouve dans le cache L2. En théorie, les informations de prédiction, présentes dans la ligne de cache, sont perdues lorsque le branchement est évincé. Mais ces processeurs conservent ces prédictions dans un cache séparé, appelé le '''''L2 Branch Cache'''''.
==Le découplage du ''front-end''==
Le séquenceur d'un processeur contient une unité de chargement, un décodeur et un chemin de données. Avec un pipeline, le couplage de ces structures fait que si l'une d'entre elle prend plus d'un cycle pour faire son travail, les étages précédents et/ou suivants sont stoppés. Par exemple, lors d'un défaut dans le cache d'instruction, l'ensemble stoppe. De même, si jamais le décodeur doit décoder une instruction via le micro-code, cela prend plusieurs cycles : l'unité de chargement et le cache d'instruction sont inutilisés. Même chose si jamais une instruction multicycle s’exécute dans le pipeline : cela bloque toutes les étapes précédentes.
Mais il s'agit là d'un défaut inhérent aux pipelines basiques, qui relient chaque étage avec des registres. Avec un peu d'astuce, il est possible que certains étages prennent de l'avance même si l'étage suivant est bloqué. Par exemple, si le décodeur bloque le pipeline en utilisant son micro-code pendant 4-5 cycles, l'unité de chargement peut en théorie précharger à l'avance les instructions suivantes, et les mettre en attente. Ou encore, en cas de bulle de pipeline (''pipeline stall''), le décodeur peut décoder en avance des instructions et les mettre en attente tant que la bulle de pipeline est en cours.
Dans les deux cas, une unité prend de l'avance et met en attente ses résultats tant que l'étage suivant est occupé. La mise en attente est réalisée en remplaçant/complémentant les registres du pipeline avec des mémoires FIFOs. Elles remplacent ou complémentant les registres de pipeline, les deux sont possibles. Si elles remplacent ces registres, si aucune mise en attente n'est requise, elles fonctionnent comme un registre de pipeline.
===La file d'instruction===
[[File:File de micro-opérations.png|vignette|upright=1|File d'instructions]]
Les processeurs modernes intègrent une '''file d'instruction''', à savoir une mémoire FIFO placée entre le cache d'instruction et le décodeur d'instruction. Les instructions chargées par l'étape de chargement soient accumulées dans la file d'instructions et sont décodées quand l'unité de décodage est prête. La file d'attente permet de charger des instructions à l'avance, permettant ainsi de masquer certains accès au cache ou à la mémoire assez longs.
L'idée est que les instructions s'accumulent dans la file d'instruction si le processeur exécute les instructions moins vite qu'il ne les charge. C'est généralement signe qu'il effectue une instruction multicycle et/ou qu'il effectue un accès à la mémoire. À l'inverse, la file d'attente se vide quand le processeur exécute les instructions plus vite qu'il n'en charge. C'est généralement signe qu'un défaut de cache d'instruction est en cours. La présence d'une file d'attente fait que la première situation est compensée lors de la seconde. Les temps d'attentes liées aux instructions multicycles permettent de remplir la file d'attente, qui est ensuite vidée en cas de défaut de cache. Le processeur exécute en permanence des instructions, sans interruption. Alors que sans file d'attente, les défauts de cache entraineront des temps d'attente où le processeur s’exécuterait rien.
La seule limite de cette optimisation est l'influence des branchements. Lorsqu'un branchement est décodé, ce tampon d’instructions est totalement vidé de son contenu. Ce n'est ni plus ni moins ce que faisait la ''prefetch input queue'' des anciens processeurs Intel, dont nous avions parlé dans le chapitre sur l'unité de chargement et le séquenceur.
===La file de micro-opérations===
[[File:File de micro-opération.png|vignette|upright=1|File d'instruction]]
L'optimisation précédente peut aussi s'appliquer entre le décodeur d'instruction et le chemin de données. Pour cela, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction. Elle mémorise les micro-opérations émises par le décodeur et les met en attente tant que le reste du pipeline n'est pas prêt. Nous l'appellerons la '''file de micro-opérations''', mais elle porte de nombreux noms et la terminologie des différents fabricants est assez confuse. Le schéma ci-contre indique que la file de micro-opérations est située en sortie de l’unité de décodage, avant l'unité d'émission et avant l'unité de renommage de registres (que nous aborderons dans quelques chapitres).
La file de micro-opérations permet aux décodeurs de faire leur travail même si le reste du pipeline n'est pas prêt. Par exemple, imaginons que le processeur ne peut pas émettre de nouvelle instruction, soit car toutes les ALUs sont occupées, soit car il y a un accès mémoire qui bloque le pipeline, peu importe. Sans file de micro-opérations, tout ce qui précède l'unité d'émission devrait être totalement bloqué tant que l'instruction ne peut pas être émise. Mais avec une file de micro-opérations, le pipeline peut charger et décoder des instructions, il a juste à accumuler les instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions décodées quand des bulles de pipeline sont émises.
Et à l'inverse, elle permet d'émettre des instructions quand les unités de décodage/chargement sont bloquées. Le cas classique est celui d'un défaut de cache dans le cache d'instruction. Pendant un défaut de cache, aucune instruction n'est chargée ni décodées durant quelques cycles. Sans file de micro-opérations, le processeur ne peut plus rien faire durant quelques cycles. Mais avec une file de micro-opérations, il se rattrape en émettant les instructions en attente dans la file de micro-opérations, s'il y en a. En clair, si l'unité d'émission a mis en attente des instructions, le processeur se rattrape au prochain défaut de cache d'instruction.
Une autre situation où le décodeur bloque est le cas où certaines instructions mettent du temps à être décodées. C'est notamment le cas de certaines instructions complexes, dont le décodage prend facilement 2 à 3 cycles d'horloge, voire plus. Le pire est le décodage des instructions microcodées, qui peut demander plusieurs cycles. Or, le pipeline demande qu'on décode une instruction par cycle pour éviter de bloquer le pipeline. Mais ce temps de décodage peut être masqué si des micro-opérations sont en attente dans la file, elles sont exécutées pendant le décodage long.
===Le ''Loop Stream Detector''===
Les boucles sont une opportunité d'optimisation très intéressante sur les CPU avec une file de micro-opérations. L'idée est que lors d'une boucle, des instructions sont chargées, décodées et exécutées plusieurs fois de suite. Mais à chaque répétition d'une instruction, le chargement et le décodage donnent toujours le même résultat, seule l'exécution n'est pas la même (les registres renommés sont aussi différents, mais nous verrons cela dans plusieurs chapitres). L'idée est simplement de mémoriser les N dernières instructions décodées et de les ré-exécuter si besoin. Ainsi, on évite de charger/décoder une même instruction machine plusieurs fois, mais de réutiliser les micro-opérations déjà décodées.
L'implémentation la plus simple utiliser la file de micro-opérations comme une sorte de pseudo-cache FIFO. La file de micro-opérations ne supprime pas les micro-opérations une fois qu'elles sont émises. Elle mémorise là où se trouve la dernière micro-opération émise, mais conserve celles qui ont déjà été émises. Un circuit annexe, appelé le '''''Loop Stream Detector''''' (LSD), détecte les boucles dans la file de micro-opérations et optimise leur exécution. Si une boucle adéquate est détectée par le ''Loop Stream Detector'', les micro-opérations de la boucle sont lues dans la file de micro-opération et sont injectées directement dans la suite du pipeline. De plus, les unités de chargement et de décodage sont désactivées pendant l’exécution de la boucle, ce qui réduit la consommation d'énergie du CPU.
L'optimisation accélère les petites boucles, qui tiennent toutes entières dans la file de micro-opérations, et sous condition qu'elles s'exécutent de la même manière à chaque exécution. De telles boucles exécutent une suite de N instructions, qui reste identique à chaque itération de la boucle. Le cas le plus simple est celui d'une boucle dans laquelle il n'y a pas de branchements. Pour les boucles normales, le processeur reprend une exécution normale quand on quitte la boucle ou quand son exécution change, par exemple quand un if...else, un return ou tout autre changement de flot de contrôle a lieu. Vu que toutes ces situations impliquent un branchement qui n'a pas été pris comme avant, le processeur n'utilise plus le ''Loop Stream Detector'' en cas de mauvaise prédiction de branchement.
Le LSD vise surtout à désactiver les décodeurs et l'unité de chargement lors de l'exécution d'une boucle. La désactivation peut être du ''clock gating'', voire du ''power gating'', être partielle ou totale. Dans le pire des cas, les unités de chargement peuvent continuer à charger des instructions en avance dans une file d'instruction, mais les décodeurs peuvent être désactivés. Dans le meilleur des cas, la totalité de ce qui précède la file de micro-opération est désactivé tant que la boucle s’exécute normalement. Y compris le cache de micro-opération.
[[File:Loop Stream Detector.png|centre|vignette|upright=2|Loop Stream Detector]]
Évidemment, la taille des boucles optimisées ainsi est limitée par la taille de la file de micro-opération, ce qui fait que l'optimisation ne fonctionne que pour des boucles de petite taille. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 micro-opérations. De plus, toute la file de micro-opération n'est pas gérée par le ''loop stream detector''. Par exemple, les processeurs avec une file de micro-opération de 64 micro-opération peuvent gérer des boucles de maximum 32 à 40 micro-opérations.
Mais les contraintes principales portent sur la détection des boucles. Le ''Loop Stream Detector'' ne peut pas détecter toutes les boucles qui existent, et certaines boucles ne sont pas détectées. Par exemple, le ''Loop Stream Detector' ne peut pas détecter les boucles si un appel de fonction a lieu dans la boucle. Il y a aussi des contraintes quant au nombre de branchements à l'intérieur de la boucle et le nombre d'accès mémoire.
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les micro-architectures Zen 4 mais il a disparu sur la micro-architecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la micro-architecture Zen 4 d'AMD ou les CPU de micro-architecture Skylake et Kaby Lake d'Intel. Pour la micro-architecture Skylake, les raisons officielles pour cette désactivation sont un bug lié à l'interaction avec l'''hyperthreading''. Il est vraisemblable que des bugs ou des problèmes de sécurité aient amené à la désactivation sur les autres architectures.
===Le cache de micro-opérations===
Le '''cache de micro-opérations''' a le même but que le ''Loop Stream Detector'', à savoir optimiser l'exécution des boucles. La différence avec le ''Loop Stream Detector'' est qu'il y a un cache séparé de la file de micro-opérations, qui mémorise des micro-opérations décodées, dans le cas où elles soient réutilisées par la suite. La première itération d'une boucle accumule les instructions décodées dans le cache de micro-opérations, les itérations suivantes de la boucle lisent les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois. Sur de nombreux processeurs, le cache de micro-opération est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ainsi, seules les micro-opérations émises sont copiées dans ce cache.
[[File:File de micro-opérations et cache de micro-ops.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
Les avantages sont les mêmes qu'avec un ''Loop Stream Detector'' : une consommation énergétique réduite, des performances légèrement améliorées. Le décodeur et l'unité de chargement sont inutiles en cas de succès dans le cache de micro-opération, ce qui fait qu'ils sont désactivés, éteints, ou du moins subissent un ''clock-gating'' temporaire. Ils ne consomment pas d'énergie, seul le cache de micro-opération utilise de l'électricité. L'avantage en termes de performance est plus faible, assez variable suivant la situation, mais aussi bien le cache de micro-opérations que le LSD ne font pas de mal.
Une différence avec le cache de micro-opération est qu'une boucle doit s’exécuter à l'identique avec un ''Loop Stream Detector'', pas avec un cache de micro-opérations. Prenons l'exemple d'une boucle contenant quelques instructions suivies par un IF...ELSE. Il arrive qu'une itération de la boucle exécute le IF, alors que d'autres exécutent le ELSE. Dans ce cas, le ''Loop Stream Detector'' ne sera pas activé, car la boucle ne s’exécute pas pareil d'une itération à l'autre. Par contre, un cache de macro/micro-opération mémorisera les micro-opérations du IF et du ELSE et les fournira selon les besoins. La raison est que le cache de micro-opérations a une politique de remplacement des lignes de cache plus complexe que le FIFO, typiquement une politique LRU ou LFU approximée. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', pour un cout en transistor plus élevé.
Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. L'accès au cache de micro-opération se fait lors de l'étape de chargement. Le cache de micro-opérations est adressé en envoyant le ''program counter'' sur son entrée d'adresse, en parallèle du cache d'instruction. En clair, il y a une voie qui regroupe cache d'instruction, file d'instruction et décodeur, et une seconde voie qui se résume au cache de micro-opération. Les deux voies sont accédées en parallèle. En cas de succès dans le cache de micro-opération, les micro-opérations adéquates sont lues directement depuis le cache de micro-opération.
Le cache de micro-opération associe, pour chaque instruction machine, une ou plusieurs micro-opérations. Avec l'implémentation la plus simple, une ligne de cache est associée à une instruction machine. Par exemple, sur les processeurs Intel de micro-architecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. Une instruction devait tenir toute entière dans une ligne de cache, ce qui fait que les instructions décodées en plus de 6 micro-opérations ne pouvaient pas rentrer dans ce cache.
Il existe deux méthodes différentes pour encoder les micro-opérations dans le cache de micro-opérations. La première est la plus intuitive : on mémorise les micro-opérations dans la ligne de cache, directement. Elle est utilisée sur les processeurs AMD, et sans doute sur les processeurs Intel récents. Mais les anciens processeurs Intel, comme ceux des architectures Sandy Bridge et Netburst, utilisent une autre méthode. Une ligne de cache mémorise non pas les micro-opération directement, mais un pointeur vers le ''control store'', qui indique à quelle adresse dans le micro-code se situe la micro-opération. La micro-opération est donc lue depuis le micro-code lors de son envoi au chemin de données, lors de son émission.
Il faut noter que pour des raisons de performance, le cache de micro-opérations est virtuellement tagué, ce qui fait qu'il est invalidé en cas de changement de programme. Sur l'architecture Sandy Bridge, il est carrément inclus dans le cache L1, les deux sont des caches inclusifs l'un avec l'autre. Les premières implémentations étaient très limitées. Les micro-opérations devaient être séquentielles dans le code, le cache était consulté seulement après un branchement et non à chaque instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Le cache de micro-opérations et le ''Loop Stream Detector'' font la même chose, mais certains processeurs implémentaient les deux. L'avantage est que le cache de micro-opération peut être désactivé si jamais le LSD détecte une boucle dans la file d'instruction, ce qui réduit encore plus la consommation énergétique. En pratique, l'impact sur la consommation énergétique est très difficile à mesurer, mais il rajoute de la complexité pour la conception du processeur.
[[File:File de micro-opérations et cache de micro-ops - Copie.png|centre|vignette|upright=2.5|File de micro-opérations et cache de micro-ops - Copie]]
===La ''Fetch Target Queue''===
Nous venons de voir qu'il est possible de découpler les étages de chargement, décodage et le chemin de données, en insérant des mémoires FIFOs dans le pipeline. Il en est de même avec l'unité de chargement elle-même. Elle est composée de deux circuits qu'on peut découpler : le cache d'instruction, l'unité de prédiction de branchement, et l'unité de calcul d'adresse. L'unité de calcul d'adresse regroupe : le ''program counter'', de quoi l'incrémenter, les MUX associés pour gérer les branchements.
Les processeurs modernes découplent l'unité de calcul d'adresse du cache d'instruction, en insérant une mémoire FIFO entre les deux. Les premiers articles scientifiques, qui ont proposé cette solution, l'ont appelée la '''''Fetch Target Queue''''', abréviée FTQ. Elle accumule les adresses à lire dans le cache d'instruction, peu importe que ces adresses viennent du ''program counter'' ou de l'unité de prédiction de branchement.
[[File:Fetch target queue.png|centre|vignette|upright=2.5|Fetch target queue]]
Elle se remplit quand le cache d'instruction est bloqué, soit à cause d'un défaut de cache, soit à cause d'un pipeline bloqué en amont de l'unité de chargement. Par exemple, si le cache d'instruction est bloqué par un défaut de cache, l'unité de prédiction de branchement peut accumuler des prédictions à l'avance dans la FTQ, qui sont ensuite consommées par le cache d'instruction une fois qu'il est redevenu disponible. De même, si l'unité de prédiction de branchement est bloquée par un évènement quelconque, le cache d'instruction peut consommer les prédictions faites à l'avance.
Une utilisation assez originale de la FTQ s'est vu sur les processeurs AMD d'architectures bulldozer. Sur cette architecture, les cœurs étaient regroupés par paquets de deux, et les deux cœurs partageaient certains circuits. Notamment, l'unité de prédiction de branchement était partagée entre les deux cœurs ! Pourtant, chaque cœur disposait de sa propre FTQ !
==Le ''Branch Folding'' et la file d'instruction==
Une file d'instruction basique précharge les instructions qui suivent la dernière instruction chargée. Mais une telle file d'instruction gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées. De plus, les instructions préchargées par erreurs doivent être supprimées pour éviter qu'elles soient décodées et exécutées, ce qui fait que la file d’instruction doit être invalidée.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Heureusement, il existe des techniques qui visent à réduire l'impact des branchements sur la file d'instruction. L'idée est d'exécuter les branchements à l'avance, avant même leur décodage ! L'optimisation en question est appelée le '''''Branch Folding''''', et a été utilisé sur les processeurs PowerPC, pour ne citer qu'eux.
Le ''branch folding'' a une ressemblance très forte avec le préchargement appliqué aux instructions. Il faut dire qu'une file d'instruction a pour rôle de charger des instructions ''à l'avance''. La différence est la cible : dans la file d'instruction pour le ''branch folding'', dans le cache pour le préchargement d'instruction. Pour faire la distinction avec les autres formes de préchargement, nous parlerons de '''préchargement interne'''.
Les algorithmes utilisés pour le préchargement interne sont cependant les mêmes que pour le préchargement d'instruction. Aussi, nous allons les voir en même temps. Le préchargement séquentiel a pour équivalent... la file d'instruction elle-même. Du moins, dans son fonctionnement normal. Voyons maintenant les autres algorithmes.
===Le ''target line prefetching''===
Une version améliorée prend en charge les branchements d'une manière un peu plus naturelle. Elle agit comme le préchargement séquentiel, sauf quand elle rencontre un branchement inconditionnel. Dans ce cas, elle précharge les instructions à partir de l'adresse de destination du branchement. Il s'agit du '''''target line prefetching'''''.
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
La technique demande de détecter les branchements inconditionnels. Pour cela, une unité spécialisée scanne la file d'instruction, pour y trouver les branchements. L'unité en question s'appelle l''''unité de ''branch folding'''''. Si un branchement inconditionnel est détecté dedans, elle utilise le ''target line prefetching''. L'adresse de destination est extraite du branchement inconditionnel, et le préchargement continue à partir de cette adresse. Pour cela, le ''program counter'' est altéré en avance. De plus, le branchement est remplacé par l'instruction de destination du branchement. Les instructions qui "suivent" le branchement sont annulées avant, histoire d'éliminer les instructions chargées à tord.
[[File:Branch folding.png|centre|vignette|upright=2|Branch folding.]]
Maintenant, qu'en est-il des branchements conditionnels ? La réponse de si la prédiction de branchement est utilisée ou pas. Sans prédiction de branchement, les branchements conditionnels sont considérés comme non-pris. Avec prédiction de branchement, tout dépend de si le branchement est prédit comme pris ou non. Si le branchement est considéré comme pris, il est traité comme un branchement inconditionnel : il est remplacé par l'instruction de destination et le ''program counter'' est altéré. Si le branchement est non-pris, il suffit de ne rien faire et de continuer le préchargement séquentiel.
Notez qu'il faut que l'adresse de destination soit connue à l'avance, ou du moins puisse être calculée facilement (branchements relatifs). L'unité de branchement peut contenir un additionneur afin de calculer l'adresse de destination d'un branchement relatif.
===Le préchargement du mauvais chemin===
Le ''target line prefetching'' peut être associé avec le préchargement séquentiel, pour gérer les branchements conditionnels au miuex. L'idée est de précharger à la fois les instructions après le branchement et celles à sa destination. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates auront été préchargées. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching''). La technique était implémentée sur le mainframe IBM 370/165 et quelques modèles IBM similaires. Les processeurs PowerPC utilisaient aussi cette technique.
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le préchargement du mauvais chemin demande d'utiliser deux files d'instructions séparées. L'une dans laquelle on précharge de manière séquentielle, l'autre utilisant la ''target line prefetching''. Le choix de la bonne file se fait avec un multiplexeur. Une fois le branchement conditionnel exécuté, on sait s'il est pris ou non, le contenu de la file adéquate est conservé, l'autre est intégralement invalidée.
[[File:Branch folding avec prédiction du mauvais chemin.png|centre|vignette|upright=2|''Branch folding'' avec prédiction du mauvais chemin.]]
Le préchargement du mauvais chemin réduit un peu la pénalité en cas de mauvaise prédiction de branchement, en économisant quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque charger l'instruction de destination déclenche un défaut de cache. Le préchargement déclenche alors le défaut de cache en avance de quelques cycles, qui peuvent être traité avant même que la mauvaise prédiction de branchement soit détectée.
Elle a le défaut de bouffer beaucoup de débit binaire, au niveau du cache d'instruction. Elle fonctionne mieux si l'on peut précharger deux instructions par cycle, une par chemin. Et cela demande un cache d'instruction double port, dont le cout en circuit n'est pas négligeable. Et le gain en performance est assez faible.
Les premiers processeurs PowerPC utilisaient cette optimisation. Ils avaient une file d'instruction de 8 instructions, et l'unité de branchement scannait les 4 premières instructions. L'unité de ''branch folding'' contenait les registres nécessaires pour gérer les branchements. Elle contenait précisément le ''link register'' pour des appels de procédures, le ''Count Target Register'' et le ''Count Register'' utilisés pour les boucles. Elle contient aussi un additionneur pour calculer les adresses des branchements relatifs.
L'unité de ''branch folding'' identifiait les branchements, sans décoder les instructions en attente. A la place, les instructions étaient pré-décodées dans le cache d'instruction, qui identifiait la position des branchement. Les informations de prédécodage étaient utilisées pour remplir la file d'instruction. Chaque instruction dans la file d'instruction est associée à un bit qui indique si c'est un branchement ou non. L'unité de branchement anticipé peut alors détecter les branchements avec un simple encodeur à priorité.
[[File:Branch folding des CPU PowerPC.png|centre|vignette|upright=2|Branch folding des CPU PowerPC - les bits de prédécodage sont en couleur. Les branchement sont en rouge, les autres instructions en jaune.]]
===Le préchargement du mauvais chemin avec plusieurs branchements consécutifs===
Un défaut du préchargement du mauvais chemin est qu'elle gère mal le cas où deux branchements sont assez proches dans le code machine. Un branchement scinde le flux d'instructions : un où le branchement est pris, un autre où il ne l'est pas. Chacun de ces flux peut lui-même contenir un branchement, et se scinder lui aussi. Et ainsi de suite. Et le processeur doit gérer cette situation en termes de préchargement.
[[File:Exécution stricte 04.png|centre|vignette|upright=2|Exécution stricte]]
Plusieurs solutions existent. La première est de ne pas utiliser le ''target line prefetching'' au-delà de deux branchements. La méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Une solution un peu moins intuitive utilise le préchargement du mauvais chemin pour le premier branchement, mais bascule sur le préchargement séquentiel pour les branchements suivants. Cette solution est celle qui est systématiquement implémentée, car elle est simple à implémenter. Une dernière possibilité est d'utiliser la prédiction de branchement au-delà du premier branchement, afin de poursuivre le chargement de manière spéculative.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
Une autre possibilité consiste à utiliser le préchargement du mauvais chemin pour le second branchement, ainsi que ceux des flux extérieurs. Cette dernière est impraticable car elle demande des caches avec un grand nombre de ports et la présence de plusieurs files d'instructions, qui sont utilisées assez rarement. Et quitte à gaspiller autant de transistors, autant les utiliser décoder/exécuter directement les instructions, au lieu de les accumuler dans des files d'instruction. C'est ce qu'on appelle l''''exécution stricte''' (''eager execution'').
Quelques papiers de recherche ont étudié l'idée, mais ses défauts font qu'elle n'a jamais été utilisée dans un processeur en dehors de prototypes de recherche. Le gros problème de l'exécution stricte est qu'on est limité par le nombre d'unités de calculs, de registres, etc. Autant ce serait une technique idéale sur des processeurs avec un nombre illimité de registres ou d'unités de calcul, autant ce n'est pas le cas dans le monde réel. Au bout d'un certain nombre d’embranchements, le processeur finit par ne plus pouvoir poursuivre l’exécution, par manque de ressources matérielles et doit soit stopper, soit recourir à la prédiction de branchement.
==Le préchargement d'instructions==
Dans le chapitre sur le préchargement, nous avons volontairement mis de côté les techniques de ''préchargement d'instruction'', car ce n'était pas le moment d'en parler. Mais le moment est venu d'aborder le préchargement pour les instructions, d’où cette section. La section précédente parlait d'une forme de préchargement qui n'en est pas vraiment, car elle n'implique pas de mémoire cache. Par contre, le préchargement que nous allons voir le fait. Le '''préchargement d'instruction''' charge des instructions dans le cache L1 d’instruction. Les instructions proviennent du cache L2, qui mémorise aussi bien données qu'instructions. Aussi, nous parlerons de '''préchargement L2-L1i''', sous entendu : du cache L2 vers le cache L1 d'instruction.
Pour comprendre comment s'effectue le préchargement L2-L1i, rappelons que l'unité de chargement émet des adresses consommées par le cache d'instruction. Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Idem en cas de blocage du pipeline au-delà du cache, par exemple en cas de bulle de pipeline prolongée. Et c'est lors de ces blocages que le préchargement d'instruction se met en place. Lors de ces blocages, une unité de préchargement envoie des lectures au cache L2, pour précharger les instructions adéquates. Elle prédit quelles sont les instructions futures soit en se basant sur la prédiction de branchements, soit en regardant le contenu de la FTQ, soit autrement.
[[File:Préchargement d'instructions.png|centre|vignette|upright=2.5|Préchargement d'instructions]]
L'unité de préchargement peut utiliser n'importe quel algorithme de préchargement : du préchargement séquentiel, du ''target-line prefetching'', ou du préchargement du mauvais chemin.
* Avec le préchargement séquentiel, le préchargement charge la ligne de cache suivante à la dernière ligne de cache accédée.
* Le ''target-line prefetching'' s'enclenche en présence de branchements : l'unité de prédiction de branchement donne l'adresse de destination d'un branchement, et c'est la ligne de cache de la destination qui est chargée.
* Avec le préchargement du mauvais chemin, deux lignes de cache sont préchargées : celle avec ''target-line prefetching'', celle avec préchargement séquentiel. Elle n'a de sens que pour les branchements conditionnels.
Le préchargement séquentiel est facile à implémenter, car il n'a pas à utiliser la prédiction de branchement. Il a juste à analyser les derniers accès mémoire du cache d'instruction. Il n'a pas de différence avec un ''prefetcher'' séquentiel, tel qu'on l'a vu dans le chapitre sur le préchargement. Les deux autres formes de préchargement sont elles bien plus compliquées.
===L'anticipation du ''program counter''===
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
===Le ''Fetch Directed Instruction Prefetching''===
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=L'émission des instructions
| nextText=L'émission des instructions
}}
</noinclude>
{{AutoCat}}
9itikelnn9vx6h25qzwssq8sck2lwsa
Mathc initiation/a594
0
81090
774813
774812
2026-10-11T12:03:39Z
Xhungab
23827
774813
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
'''Deux propriétés :'''
* Si f(t) est '''paire''', alors '''bn = 0'''.
* Si f(t) est '''impaire''', alors '''a0 = 0 et an = 0'''.
.
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(x) = x ...................... avec x = [0..PI]]]
* [[Mathc initiation/007n|La fonction : f(x) = x^2 .................. avec x = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(x) = (x(x+1)) ........... avec x = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
{{AutoCat}}
rr25xmp2uruwu8lfvhnv13whyxem8lv
774882
774813
2026-10-11T18:14:11Z
Xhungab
23827
774882
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
'''Deux propriétés :'''
* Si f(t) est '''paire''', alors '''bn = 0'''.
* Si f(t) est '''impaire''', alors '''a0 = 0 et an = 0'''.
.
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
{{AutoCat}}
2zhwubghw89orkhcmtlthbs3isfsdkw
Mathc initiation/a593
0
81091
774906
774748
2026-10-11T18:57:03Z
Xhungab
23827
774906
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [0..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook 5.png|thumb]]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
mescklbqzq80my0yfhlky3uxabuptdm
774913
774906
2026-10-11T19:03:26Z
Xhungab
23827
774913
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [0..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:4*pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook 5.png|thumb]]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/a598|c0fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/a615|c0fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9d|c0fsap.c]]
{{AutoCat}}
jiu64yb1d84b2dnicggauii7tyybreg
Fonctionnement d'un ordinateur/Les pipelines in-order
0
84572
774814
774801
2026-10-11T14:02:24Z
Mewtow
31375
/* Les instructions multi-cycles */
774814
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées. Fort heureusement, nous n'avons besoin que de nous préoccuper d'un cas bien précis, pour le moment.
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
===Le registre de disponibilité===
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
6o35sior7sxancbenut7fb12eej8gnj
774815
774814
2026-10-11T14:05:23Z
Mewtow
31375
/* Les dépendances d'instruction */
774815
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
===L'unité d’émission===
Les processeurs détectent les dépendances RAW dans un étage du pipeline appelé l''''étage d'émission'''. Il contient une '''unité d'émission''', séparée du décodeur d'instruction, qui détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, elle décide de bloquer le pipeline quand c'est nécessaire. C'est aussi elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache.
A chaque cycle, l'unité d'émission reçoit une micro-opération et décide quoi en faire. Si une instruction a une dépendance bloquante, elle est bloquée dans l'étage d'émission en attendant de pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW===
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===La détection des dépendances de données===
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
===Le registre de disponibilité===
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2q6uo1h8p27t4bl9srofi4quk8522kz
774816
774815
2026-10-11T14:19:13Z
Mewtow
31375
/* Les dépendances d'instruction */
774816
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===L'unité d'émission===
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
rvdnivtb7l4nometybjoviipo3copdv
774817
774816
2026-10-11T14:22:03Z
Mewtow
31375
/* Les dépendances de données */
774817
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===L'unité d'émission===
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Le cas qui nous intéresse est celui où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
7des9q6zupm3ky3ka3sxux8q74nhioq
774818
774817
2026-10-11T14:23:17Z
Mewtow
31375
/* Les dépendances RAW : détection par l'unité d'émission */
774818
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===L'unité d'émission===
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
1qt0badwg72xlszb4nfbljp5hnrlqwt
774819
774818
2026-10-11T14:24:08Z
Mewtow
31375
/* Les dépendances d'instruction */
774819
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction, par un circuit spécialisé, dont nous devons toucher quelques mots.
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ml17hzbxp218hw9d1oawnr8b0bte80i
774820
774819
2026-10-11T14:27:43Z
Mewtow
31375
/* Les dépendances de données : registres et adresses */
774820
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction, par un circuit spécialisé, dont nous devons toucher quelques mots.
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| ''Program Counter'' || Cache d'instruction || Décodeur d'instruction || class="f_rouge" | Unité d'émission || Port de lecture du banc de registre (registres) || Unités de calcul / Unité mémoire || Port d'écriture du banc de registre
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
317vgf5ukyqytu9ampx67s0tnzhkva3
774821
774820
2026-10-11T14:30:20Z
Mewtow
31375
/* Les dépendances de données : registres et adresses */
774821
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées après l'étape de décodage d'instruction, par un circuit spécialisé, dont nous devons toucher quelques mots.
Les processeurs détectent les dépendances de registre dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. Elle détecte les dépendances entre instructions et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
027g0ad07qis7dnfucy7t16771kw0qs
774822
774821
2026-10-11T14:31:18Z
Mewtow
31375
/* Les dépendances de données : registres et adresses */
774822
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances de registre===
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
:Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ceux qui supportent des instructions multicycles, ou si les écritures prennent plusieurs étages.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
hno0jqg86sjob260rm6wtp9wpj4hkdk
774823
774822
2026-10-11T14:35:49Z
Mewtow
31375
/* Les dépendances de registre */
774823
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
p5cc5g6no1tr3f11ac8et8unrefcf1o
774824
774823
2026-10-11T14:35:59Z
Mewtow
31375
/* Les dépendances RAW : détection par l'unité d'émission */
774824
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
l9sv1llyazrnywuo4mowbmwu7qajqno
774825
774824
2026-10-11T14:41:38Z
Mewtow
31375
/* Les dépendances de données : registres et adresses */
774825
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ql1vhnmyv0k31k8ecsbei7k27tz1hkq
774826
774825
2026-10-11T14:42:20Z
Mewtow
31375
/* Les blocages de pipeline */
774826
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservationest aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
p16qxxov6zq8lsa6t79o6mrn426kca5
774827
774826
2026-10-11T14:53:42Z
Mewtow
31375
/* Les dépendances RAW : détection par l'unité d'émission */
774827
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Mais la majorité des processeur n'utilise pas cette solution. La solution la plus intuitive est de ''retarder la seconde instruction'', de manière à ce qu'elle fasse son écriture après que la première instruction ait fait la sienne. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
icabkdjw5zo5p5wqw3jpqspr0rihezr
774828
774827
2026-10-11T14:57:17Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774828
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate (dont on vérifie les dépendances) a le même registre de destination qu'une instruction en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus. Une implémentation plus optimisée
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. La raison est que la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ca donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Mais avec un pipeline dynamique, voici ce que ca donne. L'ordre des écritures s'est inversé, ce qui fait que les instructions dépendantes du résultat de l'addition ne liront pas la bonne opérande. En clair : le programme ne se comporte pas comme il le faut.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture || || ||
|}
Pour régler ça, l'unité d'émission peut retarder l'addition tant que la multiplication n'est pas totalement terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! ... !! ... !! ... !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture || ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
9iyxwcwshhe7q0gp7t9ugjcowz238y8
774833
774828
2026-10-11T15:04:08Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774833
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
cz6199194dks2hnsef057zaxmt4c8li
774834
774833
2026-10-11T15:04:51Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774834
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission doit être assez complexe, car elle doit savoir à quel cycle d'horloge uns instruction en vol va faire son écriture. Et mémoriser les ''timings'' exacts pour chaque instruction est assez complexe. Et c'est le rôle d'un circuit appelé le '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
1ns0st4cqlms0kkhtec94xev3qmlw7y
774835
774834
2026-10-11T15:05:22Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774835
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages dans le pipeline, précisément autant d'étages que pour l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits, un par étage du multiplieur (on suppose que l'étage de ''writeback'' est compté dans ces cycles).
L'idée est que quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ng3kdsn3nrv0ub5uswbw1zcbd6t7lve
774836
774835
2026-10-11T15:07:24Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774836
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve les étages du pipeline nécessaires. Pour réserver un étage, elle met le bit correspondant à 1, dans le ''result shift register''. Le nombre de bits mis à 1 dépend du nombre de cycles de l'instruction. Par exemple, avec l'exemple précédent, une addition qui prend un cycle mettra le bit de poids faible de ce registre à 1, un décalage mettra à 1 les trois bits de poids faible, une multiplication mettra les 6 bits du registre à 1. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge , ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Etage Read Operand !! Etage Exec 1 !! Etage Exec 2 !! Etage Exec 3 !! Etage Exec 4 !! Etage Exec 5 !! Etage Writeback
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
q4d0d3ymb6x04pmdklg205oxhecez05
774837
774836
2026-10-11T15:15:38Z
Mewtow
31375
/* Les dépendances WAW : détection par l'unité d'émission */
774837
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Pour cela, on connecte la sortie de l'unité de calcul sur son entrée si besoin, et on la déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
i3qzr36wtu1ilxkwnu4ewcs255nf9an
774838
774837
2026-10-11T15:17:31Z
Mewtow
31375
/* Le contournement de l'unité de calcul */
774838
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres. Le présent chapitre est dédié uniquement à cette technique dite de contournement.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
3ab447rpsfkvix1t18swf36lef0wfg9
774839
774838
2026-10-11T15:23:36Z
Mewtow
31375
/* Le contournement : réduire les dépendances */
774839
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand unelecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
c0ooayxnia1cyw1w32v1c39p5oojv04
774840
774839
2026-10-11T15:24:07Z
Mewtow
31375
/* Le contournement des lectures mémoire */
774840
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
===Le contournement avec les registres d'échelonnage===
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
3c7h262fiyqhhufacwjljr1rzz3ihji
774841
774840
2026-10-11T15:24:55Z
Mewtow
31375
/* Le contournement avec les registres d'échelonnage */
774841
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition d'un cycle. L'addition a pour opérande le résultat de la multiplication. Le résultat de la multiplication est disponible après 5 cycles, l'addition doit attendre 4 cycles avant d'être émise, sans quoi elle entrera dans l'ALU trop tôt, avant que le multiplieur fournisse le résultat voulu. Et qui dit attendre 4 cycles implique de bloquer le pipeline pendant 4 cycles.
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
88tt44b938x1ne3432to74yj4nqclbm
774842
774841
2026-10-11T15:29:53Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
774842
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. En effet, imaginez qu'une instruction ait pour opérande le résultat d'une instruction exécutée deux cycles plus tôt. Il y a un délai de deux cycles entre les deux, mais l'ALU a fournit un résultat en un cycle d'horloge. Où est le résultat voulu ? Il n'est pas en sortie de l'ALU, mais dans le registre d'échelonnage juste après. Le contournement doit donc relier les registres d'échelonnage aux entrées des unités de calcul.
Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs). Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Mais faire ainsi nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
8ijkm0y8m8rwygex48eiiisukt248s1
774843
774842
2026-10-11T15:32:54Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
774843
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Pour détecter les dépendances, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
sl1amhp05n6kilqh0ljsu0vskxuf1qq
774844
774843
2026-10-11T15:36:26Z
Mewtow
31375
/* Le contournement de l'unité de calcul */
774844
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie les interconnexions. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs.
Et c'est seulement pour les unités de calcul entière, il faut aussi rajouter la FPU. Une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
21i4zqmgustvbbtf3matlmmskzq9b9y
774845
774844
2026-10-11T15:37:25Z
Mewtow
31375
/* Le contournement de l'unité de calcul */
774845
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
5p80zq8max46nrmzyrrzdtzvtacq960
774846
774845
2026-10-11T15:43:43Z
Mewtow
31375
/* Le contournement de l'unité de calcul */
774846
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatible avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
hufcwcyp43bf7f4plpi4udipntmkmhq
774848
774846
2026-10-11T16:04:31Z
Mewtow
31375
/* Les instructions multi-cycles */
774848
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatibles avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Le problème se manifeste pour toutes les interruptions, que ce soit les exceptions matérielles, les interruptions matérielles et les interruptions logicielles. Mais le problème se manifeste surtout pour les exceptions matérielles, pas pour les interruptions matérielles. Les interruptions logicielles sont à part, elles sont techniquement des branchements et nous reparlerons des branchements plus tard dans ce chapitre.
Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
qtx62l5v2ls44i9rbqph8i69k6m8gc3
774849
774848
2026-10-11T16:14:37Z
Mewtow
31375
/* Les exceptions précises */
774849
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on expliquera plus bas. Dans ce cas, le processeur doit gérer la situation en bloquant le pipeline. Et au lieu de parler en généralités, abordons le sujet avec un exemple très simple : les défauts de cache.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatibles avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
bzb7asrabgelhizq25doevgrtdib13b
774850
774849
2026-10-11T16:30:40Z
Mewtow
31375
774850
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les instructions multi-cycles==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Reste à l'exploiter.
Intuitivement, les instructions multi-cycles ne semblent pas compatibles avec les pipelines du chapitre précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. De tels pipelines ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser. Il y a deux solutions générales pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ssxkwvxvkvpnufvmfc1i1u050ls908h
774851
774850
2026-10-11T16:37:12Z
Mewtow
31375
/* Les instructions multi-cycles */
774851
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles sur un pipeline fixe, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. Voyons cela en détail.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
5i7m8b4kltf9x9utuiuzjevyjgiub5f
774852
774851
2026-10-11T16:38:23Z
Mewtow
31375
/* Les instructions multi-cycles */
774852
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
L'unité d'émission envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
Exploiter plusieurs voies en même temps n'est pas ce qui différencie un pipeline dynamique d'un pipeline de longueur fixe. Un pipeline ''in-order'' peut exploiter plusieurs voies en même temps, mais cette exploitation est limitée du fait des dépendances WAW. A l'inverse, avec un pipeline dynamique, l'unité d'émission envoie une micro-opération dans une voie sans se soucier de ces dépendances, qui sont gérées par un système dédié. Tant qu'une unité est libre et qu'il n'y a pas de dépendances RAW, l'unité d'émission peut envoyer une micro-opération dedans.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
ft85o64irftqcxbijl5h7iftrq146gm
774853
774852
2026-10-11T16:39:22Z
Mewtow
31375
/* La séparation entre front-end et back-ends */
774853
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
8xf2gjuxm3dy8ikbpmgkssh5qilayq2
774854
774853
2026-10-11T16:42:18Z
Mewtow
31375
/* La séparation entre front-end et back-ends */
774854
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
iwxys8afrh36ucywjb94glvava5l9vj
774856
774854
2026-10-11T16:43:18Z
Mewtow
31375
/* Les pipelines fixes et dynamiques */
774856
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Tout cela est bel et bon, mais il y a des dépendances qui ne peuvent pas être réglées en bloquant le pipeline. Il s'agit des dépendances de contrôle, qui sont liées aux branchements, aux interruptions, et aux exceptions matérielles. Et ça sera le sujet de la fin de ce chapitre, ainsi que du suivant. Penchoins nous par un problème très propédeutique : les exceptions précises.
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
nhgykfio8z60o11fbf11wv5ualnjg41
774857
774856
2026-10-11T16:57:43Z
Mewtow
31375
/* Les exceptions précises */
774857
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
hcslun0xq2tq4iovfbtklfbqrj4gh0r
774865
774857
2026-10-11T17:11:47Z
Mewtow
31375
774865
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
==Les pipelines fixes et dynamiques==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse.Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Il s'agit d'une dépendance de données assez particulière, et précisément une dépendance d'adresse. Il est possible d'éviter cela à l'émission, bloquant le pipeline, mais cela demande de comparer des adresses et c'est clairement compliqué. Aussi, une autre solution utilise la file d'écriture pour résoudre ce problème.
L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO. Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le pipeline
| prevText=Le pipeline
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
31fqny3yq1qhk6ogjonx9v19pccywid
774871
774865
2026-10-11T17:46:11Z
Mewtow
31375
/* Les exceptions précises */ Déplacement autre chapitre
774871
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
==Les pipelines fixes et dynamiques==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
===Les pipelines fixes et dynamiques===
Même en ajoutant plusieurs avals, les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre.
Il parait plus logique que le pipeline ait un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Le problème est que les pipelines dynamiques sont bien plus compliqués que les autres. Tellement compliqués qu'on leur dédiera un chapitre dédié. Dans ce chapitre, nous allons rester sur les pipelines fixes. Un pipeline fixe peut supporter les instructions multi-cycles, mais cela demande de ruser.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
6p2o75p6go9l1w903be6f02w8rhxspx
774876
774871
2026-10-11T17:52:33Z
Mewtow
31375
774876
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines fixes et dynamiques==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
kki34ysjrbmvnzfndlod2j5ml9vg25r
774877
774876
2026-10-11T17:52:40Z
Mewtow
31375
774877
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines fixes et dynamiques==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
13wossysuvcdygyhb40yyp5i7mbwke5
774878
774877
2026-10-11T17:53:18Z
Mewtow
31375
/* Les pipelines fixes et dynamiques */
774878
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Prenons l'exemple d'un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
dhc53aiymn4losl2fl4u38bau5i0cyk
774879
774878
2026-10-11T17:56:58Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774879
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
7knaqq13v8h1p5b40wjtmzbxe00ua6e
774880
774879
2026-10-11T18:02:40Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774880
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les instructions multi-cycles==
Il y a deux solutions pour gérer les instructions multi-cycles, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Au passage, vous avez peut-être pensé qu'il n'est pas nécessaire de bloquer le pipeline, vu que l'ALU entière est libre. Vous pensez sans doute qu'on peut faire une addition en parallèle d'une instruction multi-cycles. Mais cela demande des techniques avancées, qu'on verra dans quelques chapitres. En effet, faire ainsi a des conséquences assez subtiles, que ce soit au niveau des écritures dans les registres, ou des exceptions matérielles. Nous détaillerons cela dans la section suivante, qui porte sur les dépendances de données.
Pour donner un exemple, imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Et s'il s'agit là d'un exemple, de nombreuses situations tordues surviennent quand on ne bloque pas le pipeline lors d'une instruction multi-cycle.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances WAW : détection par l'unité d'émission===
Passons maintenant aux dépendances WAW. Elles apparaissent uniquement quand une instruction multicycle est en vol dans le pipeline. Plus haut, j'avais évoqué le fait que si une instruction multicycle occupe la FPU ou un multiplieur, l'ALU entière est libre. Il est alors possible de ne pas bloquer le pipeline, et de lancer, mettons une addition dans l'étage d'exécution. Le processeur exécute alors deux instructions en même temps, dans le même étage d’exécution, mais dans des circuits séparés. C'est parfaitement possible, mais cela demande justement de gérer deux situations : une dépendance structurelle, et une dépendance WAW.
Commençons avec la dépendance WAW. En utilisant l'optimisation mentionnée plus haut, il se peut que deux instructions consécutives ne se terminent pas dans le même ordre. Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé. Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre si elles n'ont pas de dépendances entre elles. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion fait que les dépendances WAW sont violées, causant de nombreux problèmes, des registres peuvent avoir une valeur invalide.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, second exemple. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
Une solution la plus intuitive ''retarde la seconde instruction'' jusqu'à ce que les dépendances WAW disparaissent. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
t0cs576ohl7tvd75n5oqtvaahj0kc4v
774881
774880
2026-10-11T18:12:11Z
Mewtow
31375
774881
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
Comment concilier pipeline fixe et instructions multi-cycles ? Une solution assez brute de décoffrage est de se caler sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
5fb5ackaz3r4tqmpotg6lworpiu7cfw
774883
774881
2026-10-11T18:14:22Z
Mewtow
31375
/* Les registres d'échelonnement */
774883
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. La solution en question utilise un seul cycle d'exécution, qui prend un cycle d'horloge. Par contre, les instructions multi-cycles occupent l'étage d'exécution pendant plusieurs cycles. Les multiplications restent pendant plusieurs cycles dans le multiplieur entier, idem pour les opérations flottantes qui restent dans la FPU pendant plusieurs cycles. Par exemple, une multiplication qui prend 8 cycles occupe l'étage d'exécution pendant 8 cycles consécutifs. Et pendant ces 8 cycles, l'instruction suivante attend. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Et là encore, c'est l'unité d'émission qui se charge de ce travail. Elle détecte les dépendances WAW, et bloque le pipeline si elle en rencontre une. Et pour cela, elle peut utiliser plusieurs méthodes.
Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
jhqaym48773vbjvzqbcxz870f9r72fv
774886
774883
2026-10-11T18:19:09Z
Mewtow
31375
/* Les blocages de pipeline */
774886
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
L'idée est de geler les registres de pipeline. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité de décodage, en première approximation. Après tout, c'est elle qui sait qu'elle vient de décoder une instruction multi-cycle. Elle envoie un signal ''pipeline stall'', qui gele les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près).
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Une implémentation basique part du principe que deux instructions en vol ne peuvent pas avoir le même registre de destination. L'unité d'émission teste si la micro-opération candidate a le même registre de destination qu'une micro-opération en vol. Si c'est le cas, la micro-opération candidate n'est pas émise, elle est retardée et l'unité d'émission bloque le pipeline. L'implémentation est aisée : il suffit de quelques comparateurs en plus.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
61jo3mu8ggli9qwue7sq8ejr8yg5kje
774892
774886
2026-10-11T18:28:13Z
Mewtow
31375
/* Les blocages de pipeline */
774892
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5.
Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, l'unité d'émission utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, qui contient autant de bits qu'il y a d'étages après l'unité d'émission. Il se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés arès ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
85c1xsiatn7eyrelgfuyk9m8jnnaayv
774895
774892
2026-10-11T18:30:13Z
Mewtow
31375
/* Les blocages de pipeline */
774895
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
===Les défauts de cache===
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
m83f6vgd402zqhzvw7bvbma9dpkofub
774896
774895
2026-10-11T18:30:55Z
Mewtow
31375
/* Les défauts de cache */
774896
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
398j13p496k8bdsavqfwf8h48ioewvf
774897
774896
2026-10-11T18:31:23Z
Mewtow
31375
/* Les défauts de cache */
774897
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies. Ce qui fait que nous devons nous préoccuper de seulement deux types de dépendances : les dépendances RAW et WAW. Les deux impliquent qu'une instruction écrive son résultat dans un registre, ce résultat est soit lu, soit réécrit par une seconde instruction.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
3kapwf7zbpdmkrrwl9wib61l8123x0g
774899
774897
2026-10-11T18:33:34Z
Mewtow
31375
/* Les dépendances de données : registres et adresses */
774899
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
82k976l9ns0n77m1trkpub2utddymy7
774901
774899
2026-10-11T18:49:48Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774901
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'exécution parallèle avec des instructions multi-cycles===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
k75qqyprdv4odiurz696aw53wr0cm3x
774903
774901
2026-10-11T18:51:23Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774903
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses. Premièrement, bloquer la propagation des instructions dans le pipeline, avant l'unité de décodage. Deuxièmement, envoyer des NOPs dans le chemin de données, à chaque cycle, pour que les autres unités ne fassent rien. Enfin, il faut un circuit qui décide quand bloquer le pipeline, et pour combien de temps.
Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré soit par l'unité de décodage, soit par une unité spécialisée. L'unité spécialisée sera appelée l'unité d'émission, et elle sert dès qu'on doit bloquer le pipeline. Dans les deux cas, envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité de décodage. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, le décodeur initialise un compteur quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
doj710z0qq3w9xr8j0gxvjw8jqyxf9k
774904
774903
2026-10-11T18:53:37Z
Mewtow
31375
/* Les blocages de pipeline */
774904
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Les instructions mémoire sont des instructions multi-cycles un peu spéciales, différentes des multiplications et opérations flottants. Les autres instructions multi-cycles ont un temps de calcul fixé à l'avance. Une multiplication fait toujours 8 cycles d'horloge, une addition flottante en fait 10, etc. Mais une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache.
En temps normal, la lecture fait un succès de cache, elle accède au cache L1. L'accès mémoire se fait en un cycle, parfois 2 cycles. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Une solution simple, mais très peu performante, traite les accès mémoire comme n'importe quelle instruction multicycles. L'instruction mémoire bloque le pipeline, tant qu'elle est en cours d'exécution. Quand l'accès mémoire est terminé, le pipeline est débloqué. Mais faire ainsi donnerait de très mauvaises performances, aussi ce n'est jamais la solution utilisée.
La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache. Les instructions mémoire sont traitées comme une instruction normale, qui s'exécutera en un cycle d'horloge, ce qui est bien le cas si elle fait un succès de cache. Et si un défaut de cache a lieu, on gèle le processeur à ce moment là, pas avant.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
482wms64qj0a00dqprdmkj5uvllx30e
774910
774904
2026-10-11T19:01:25Z
Mewtow
31375
/* Les défauts de cache bloquent le pipeline */
774910
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, les techniques pour ne marchent pas avec des instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
Avec une implémentation basique, tous les étages sont gelés. C'est simple, efficace, mais on peut faire un peu mieux. L'optimisation est de geler les étages situés avant l'étage MEM. Ceux situés après peuvent faire leur travail normalement. Précisons que seuls les registres de pipeline sont gelés, mais pas le banc de registre ni les autres circuits. L'étage de ''writeback'', qui enregistre les résultats dans les registres, n'est donc pas gelé. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
gf1njn419gbim3dxcfr2axnhgp9svjb
774912
774910
2026-10-11T19:02:35Z
Mewtow
31375
/* Les défauts de cache bloquent le pipeline */
774912
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, les techniques pour ne marchent pas avec des instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle, et non le décodeur d'instruction comme dit plus haut. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant. On dit que l'unité d'émission '''émet la micro-opération'''.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
hii2dgc9eblwb5qb1jx0kg5al3svg2o
774914
774912
2026-10-11T19:05:51Z
Mewtow
31375
/* L'unité d’émission : la détection des dépendances de registres */
774914
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, les techniques pour ne marchent pas avec des instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
qxzfz5ynqvnjtt823zrpddjky2r8d1q
774915
774914
2026-10-11T19:06:09Z
Mewtow
31375
/* L'émission des instructions et l'unité d'émission */
774915
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, les techniques pour ne marchent pas avec des instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
l3dwp5evs31ehv0mw6skw5zd9qplbe9
774916
774915
2026-10-11T19:06:19Z
Mewtow
31375
/* L'unité d’émission : la détection des dépendances de registres */
774916
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, les techniques pour ne marchent pas avec des instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
fm79zxu81xonqzc4y66bso3piwu5vqx
774917
774916
2026-10-11T19:07:43Z
Mewtow
31375
/* Les défauts de cache bloquent le pipeline */
774917
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'étage d’exécution contient plusieurs ALUs==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant tant que l'instruction multi-cycle n'est pas terminée. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on gèle le processeur soudainement.
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
4msze9toic7qhk7zw7jnkphkn2lt0nh
774918
774917
2026-10-11T19:14:35Z
Mewtow
31375
774918
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les instructions multi-cycles==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
hg8dhts0vju4uz1dppxmy8zv12dgcu1
774919
774918
2026-10-11T19:14:40Z
Mewtow
31375
774919
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les instructions multi-cycles==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
omogloo8c6bgt1yca49q1djqfvvthjx
774920
774919
2026-10-11T19:14:48Z
Mewtow
31375
/* Les défauts de cache bloquent le pipeline */
774920
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les instructions multi-cycles==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
LA seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
nott9euqjmhcq57gexb0b4ys8vqmk04
774921
774920
2026-10-11T19:16:12Z
Mewtow
31375
/* Les instructions multi-cycles */
774921
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les instructions multi-cycles==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, toutes les instructions font toutes plusieurs cycles. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
dq7hos91135hwj75gqu54khyh3epnwl
774922
774921
2026-10-11T19:16:40Z
Mewtow
31375
/* Les instructions multi-cycles */
774922
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les instructions multi-cycles==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
ftfbxv0bfza2gzl16qpoez1zz8gqpt5
774923
774922
2026-10-11T19:18:06Z
Mewtow
31375
/* Les instructions multi-cycles */
774923
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
===La séparation entre ''front-end'' et ''back-ends''===
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
1grcxuwh32yyt8ih3brya3vq6gxkwsf
774924
774923
2026-10-11T19:20:40Z
Mewtow
31375
/* Les pipelines fixes et dynamiques */
774924
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines avec plusieurs unités de calcul==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
20xyu7bsp5x8v2udwjq5xb99s1w18vt
774925
774924
2026-10-11T19:22:15Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines fixes]] vers [[Fonctionnement d'un ordinateur/Les pipelines in-order]]
774924
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines avec plusieurs unités de calcul==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
20xyu7bsp5x8v2udwjq5xb99s1w18vt
774930
774925
2026-10-11T19:31:48Z
Mewtow
31375
774930
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines avec plusieurs unités de calcul==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduit précocement histoire de simplifier les explications qui suivent. Pour le moment, disons qu'elle sert simplement de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
rq78wdykwolghv4to9cns7itpnobu4o
774931
774930
2026-10-11T19:35:45Z
Mewtow
31375
/* L'émission des instructions et l'unité d'émission */
774931
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines avec plusieurs unités de calcul==
Ajouter des instructions multi-cycles pose de nombreux problèmes. Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Et ajouter des unités de calcul modifie fortement le pipeline processeur.
===La séparation entre ''front-end'' et ''back-ends''===
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
9ac37e0qd5yh106s7o9rpjfz8ogyzha
774932
774931
2026-10-11T19:41:04Z
Mewtow
31375
/* Les pipelines avec plusieurs unités de calcul */
774932
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Il est possible d'exploiter plusieurs voies en même temps. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lectrue est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
srjzm2962fe3595pabs5fvcj2mk0but
774933
774932
2026-10-11T19:42:21Z
Mewtow
31375
774933
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Le pipeline permet d'exploiter plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
81slp6hluetdnbii31apojhl84bstzt
774934
774933
2026-10-11T19:48:36Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774934
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==Les pipelines fixes et dynamiques==
Les pipelines précédents ont tous un point commun : toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière. Et ce y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. Nous les appellerons des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Les processeurs commerciaux gèrent des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. Les instructions multi-cycles ne semblent pas compatibles avec un pipelines des chapitres précédents, comme le pipeline ''Fetch-Decode-Exec'' ou le pipeline RISC classique à 5 étages. Et c'est normal, ils ne le sont pas.
Les instructions multi-cycles ne semblent pas compatibles avec un pipeline. Un pipeline est censé avoir un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont appelés des ''pipelines de longueur fixe'', ou encore : des '''pipelines fixes'''. Or, on voit mal comment un tel pipeline pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
Avec la première solution, on garde un pipeline fixe. Le processeur se cale sur l'instruction la plus longue. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution. La conséquence est que pour les instructions plus rapides, certains cycles d'horloge ne serviront à rien.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
La seconde solution utilise un nombre d'étages variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Un tel pipeline est appelé un '''pipeline dynamique'''.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Nous allons voir les deux types de pipeline dans ce qui suit.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais quand on fait cela avec un mix d'instructions, qui ont des temps d'exécution variable, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline de longueur fixe, où toutes les instructions ont le même nombre d'étages. Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline dynamique, de taille variable selon l'instruction. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
ix1eqwf1i3poqpqaiwdl6orqectfnuu
774935
774934
2026-10-11T19:55:59Z
Mewtow
31375
774935
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
bf14qrhzxphb0wjcn57kr0ui12i8q5a
774936
774935
2026-10-11T19:57:18Z
Mewtow
31375
/* L'exécution parallèle avec des instructions multi-cycles */
774936
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipelines doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
A la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continuées de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multicycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multicycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire une opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registre au plus tôt, dès qu'elles ont finit leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela ganratit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deça de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
A chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soit que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). Si il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersées dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances :; de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée.Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multicycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
A chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on démarre l'instruction consommatrice, ou du moins qu'elle ait progressé suffisamment dans le pipeline.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancé immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registre opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dit libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registre d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
5neqnij8nmxal7mrl245aaiu5ndopxp
774937
774936
2026-10-11T20:00:05Z
Mewtow
31375
orthotypo
774937
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux. Le fait de séparer les registres résout ce problème de dépendances WAW : ce ne sont pas les mêmes registres qui sont écrits entre instructions normales et multi-cycles.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
1brt4bbtbe33w64h1q8zvhq384z2ntl
774938
774937
2026-10-11T20:04:42Z
Mewtow
31375
/* La solution des processeurs MIPS */
774938
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
1bykblmkkpe465t4uxdip7pu3189fn2
774939
774938
2026-10-11T20:13:06Z
Mewtow
31375
/* Les défauts de cache bloquent le pipeline */
774939
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Pourquoi ne pas laisser les instructions après la lecture continuer à s'exécuter ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instruction s après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
7eojo66n3ngegf9okwa2eqmlaoxf7bc
774940
774939
2026-10-11T20:28:34Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774940
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Pour comprendre pourquoi, imaginons qu'une lecture déclenche un défaut de cache. Pourquoi ne pas laisser les instructions après la lecture continuer à s'exécuter ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. Au cycle T+1, une micro-opération sera émise et va se propager dans le pipeline. Elle va s'arrêter à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance dépend du nombre d'étages disponibles. Il y a en général autant d'instructions en avance qu'il n'y a d'étages après l'unité d'émission. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
d058g3fw778cbucoxpitrntvksyce3t
774941
774940
2026-10-11T20:29:53Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774941
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. Au cycle T+1, une micro-opération sera émise et va se propager dans le pipeline. Elle va s'arrêter à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance dépend du nombre d'étages disponibles. Il y a en général autant d'instructions en avance qu'il n'y a d'étages après l'unité d'émission. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
7767fgmr8coy8ndg91sr5myl3z7eclt
774942
774941
2026-10-11T20:40:12Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774942
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance dépend du nombre d'étages disponibles. Il y a en général autant d'instructions en avance qu'il n'y a d'étages après l'unité d'émission. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1
|-
| colspan="8" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
ihd4729ijr0e8ip9a3ztj4540zruqad
774943
774942
2026-10-11T20:42:24Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774943
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance dépend du nombre d'étages disponibles. Il y a en général autant d'instructions en avance qu'il n'y a d'étages après l'unité d'émission. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !!
|-
| Fetch 1 || Fetch 2 || Décodage 1 || Décodage 2 || Emission || Lecture des registres || Exécution || ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
dlriy66pai6k0ulvfcwxolu40uc5av6
774944
774943
2026-10-11T20:42:59Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774944
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance dépend du nombre d'étages disponibles. Il y a en général autant d'instructions en avance qu'il n'y a d'étages après l'unité d'émission. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
af6wnvofvp48e0lxanc6s7ais08g83g
774945
774944
2026-10-11T20:49:20Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774945
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
33awbxnhvbbkk81hnfow2120us9cspu
774946
774945
2026-10-11T21:03:56Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774946
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Au pire, l'unité mémoire ne peut pas accepter de nouvelle instruction, mais le reste du pipeline n'est pas bloqué. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. Une optimisation est de continuer à émettre des micro-opérations en cas de défaut de cache. Par contre, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
82jktffx1wh86xjyvai78fmm1zn8jna
774947
774946
2026-10-11T21:06:04Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774947
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère quelques optimisations. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors gérées à part des autres. Nous les appellerons les ''instructions en avance''.
La première implémentation ne permet pas aux instructions en avance d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM. Les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
sbuqvp9tmm5684gfv06fn3ejrx2bubb
774948
774947
2026-10-11T21:20:51Z
Mewtow
31375
/* Pourquoi bloquer le pipeline en cas de défaut de cache ? */
774948
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Il y a aussi des dépendances structurelles et les dépendances de contrôle, mais nous le verrons plus tard. Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles feront l'objet du chapitre suivant, avec une introduction propédeutique à la fin de ce chapitre. Quant aux dépendances structurelles, elles sont vraiment à voir au cas par cas, ce qui fait que leur étude sera dispersée dans les chapitres qui vont suivre.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
===Les dépendances RAW : détection par l'unité d'émission===
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
9iynxzzlh05xhf8dgnskluiez875pnu
774956
774948
2026-10-11T22:04:12Z
Mewtow
31375
/* Les dépendances d'instruction */
774956
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances structurelles : l'occupation de chaque voie===
Un pipeline dynamique peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
pozdklo4o0ka3harodlm1oyjtgmnkf4
774958
774956
2026-10-11T22:05:05Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774958
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
===Les dépendances structurelles : l'occupation de chaque voie===
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
151bf6pemmh1q976zdnx3agxdhaxj1x
774959
774958
2026-10-11T22:05:46Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774959
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
teu9dne32n466e6kyky3mth0a3olovx
774961
774959
2026-10-11T22:07:29Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774961
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
2qsekam8v25l87orx8nc1u2oxzc164r
774962
774961
2026-10-11T22:07:40Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
774962
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
==Les pipelines avec plusieurs unités de calcul==
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
===L'émission des instructions et l'unité d'émission===
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===L'exécution parallèle avec des instructions multi-cycles===
Les processeurs gèrent presque tous des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles simplement. Il suffit concrètement d'ajouter un multiplieur entier, une FPU, éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite, ou du ''barrel shifter''. L’avantage est que le processeur ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''.
En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
0svx1t4gy4wqo1e8zh99cuefqs8uhpe
774987
774962
2026-10-11T23:02:26Z
Mewtow
31375
/* Les pipelines avec plusieurs unités de calcul */ Déplacement autre chapitre.
774987
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Les avals sont précédés par une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair à la fin du chapitre. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
q7kyvbk6uyl9aerrvu44cdcnr38n9y7
774989
774987
2026-10-11T23:05:28Z
Mewtow
31375
774989
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
sutaq96zabdvtinbd52r43ual1newar
774994
774989
2026-10-11T23:06:53Z
Mewtow
31375
/* La gestion de la disponibilité des ALUs */
774994
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Les dépendances structurelles sont généralement assez rares, car les pipelines prennent bien garde à bien séparer les étages du pipeline, en dupliquant des circuits s'il le faut. Mais sur les pipelines dynamiques, c'est une autre histoire. Il est fréquent qu'une instruction veuille utiliser un circuit qui est occupé par une instruction multicycle.
En théorie, une fois qu'une instruction est décodée, elle est envoyée dans la voie adaptée. Du moins, si cette voie est libre. En effet, l'usage d'instructions multi-cycles fait qu'une voie peut être occupée pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Et en général, des instructions consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer totalement ces dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Avec une seule ALU, on doit attendre qu'une multiplication en cours soit terminée avant de lancer la suivante. Mais il est possible de lancer une nouvelle multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif, surtout que le gain en performance est généralement faible. Il est en effet rare que 5 multiplications soient lancées à la suite dans le pipeline, ce qui fait que si on utilisait 5 multiplieurs, ils seraient sous-utilisés en pratique.
La seconde solution pipeline, échelonner les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit échelonné en cinq étages ? Pour certains circuits, c'est possible : on peut totalement échelonner une unité de multiplication, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge dans la même ALU, éliminant ainsi toute dépendance structurelle.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Dans la réalité, les statistiques montrent qu'il est rare que deux instructions multicycles se suivent dans un programme. La seule exception étant les instructions mémoire, mais nous les mettons à part pour le moment. En conséquence, les dépendances structurelles ont un cout en performance assez mineur. Échelonner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
sgk8w745b8x35ukjfzjm6aqu9j2n943
774995
774994
2026-10-11T23:07:09Z
Mewtow
31375
/* Les dépendances structurelles : l'occupation de chaque voie */
774995
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Les pipelines dynamiques
| nextText=Les pipelines dynamiques
}}
</noinclude>
eeovdgpoczx79tgeul0xv1t10i1hex4
775004
774995
2026-10-11T23:12:42Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
775004
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
fzex16egrwo4t3h8z84wy4xzhsu9xpg
775016
775004
2026-10-11T23:45:29Z
Mewtow
31375
/* Le contournement des instructions multi-cycles */
775016
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations==
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''. Il arrive qu'une lecture soit une instruction productrice : elle charge une opérande dans un registre, et d'autres instructions utilisent cette opérande. Les instructions consommatrices en question sont appelées des '''instructions lecture-dépendantes'''.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
nc7znmlck6n5va8whgoz5vqnnfe0avy
775017
775016
2026-10-11T23:45:52Z
Mewtow
31375
/* =L’émission anticipée des micro-opérations */
775017
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
radcjtsvoqxm7mpjbqsz26uhau03fi3
775018
775017
2026-10-11T23:46:04Z
Mewtow
31375
/* Le contournement : réduire les dépendances */
775018
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
s7opxmgdx72t0vhqkz5r0a2ne36hkv9
775020
775018
2026-10-11T23:49:45Z
Mewtow
31375
/* L’émission anticipée des micro-opérations */
775020
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==Les défauts de cache bloquent le pipeline==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont les seules instructions au temps d'exécution variable, et imprédictible. La solution utilisée par tous les processeurs dits ''in-order'', suppose que les instructions mémoire font un succès de cache et s'exécutent en un cycle d'horloge. Si un défaut de cache a lieu, on bloque le pipeline. Mais qu'est-ce que cela veut dire, "bloquer le pipeline" ?
===Le blocage du pipeline===
Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===Le blocage du pipeline par l'unité mémoire===
Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Pourquoi bloquer le pipeline en cas de défaut de cache ?===
Mais pourquoi bloquer le pipeline en cas de défaut de cache ? Par exemple, si une lecture déclenche un défaut de cache, pourquoi ne pas continuer à émettre les instructions après la lecture ? En théorie, c'est possible, c'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Si les instructions après la lecture continuaient, elles finirait par atteindre la fin du pipeline, voire par le quitter. Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Et encore plus que le nombre de cycles avant l'unité mémoire. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à prendre exactement le même nombre de cycles d'horloge. Elle utilise donc un pipeline où toutes les instructions ont le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''.
Et le processeur se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données''' et les '''dépendances structurelles'''. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres précédents.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Le processeur émet des instructions en supposant qu'une lecture entrainera un succès de cache. Et il peut se le permettre car le pipeline est bloqué en cas de défaut de cache. Un défaut de cache gèlera le pipeline, ce qui fait que l'instruction arrivera en entrée de l'ALU au bon moment, quand le pipeline sera dégelé.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
469fv4ld587en9ond4jbly0lpk59lqd
Fonctionnement d'un ordinateur/Les schedulers et stations de réservation
0
84576
774830
774795
2026-10-11T14:58:30Z
Mewtow
31375
/* Les stations de réservation */
774830
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==L'implémentation d'un ''scheduler'' : généralités==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
===La logique d'éveil et de sélection===
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
La sélection de la micro-opération à émettre est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'exécution dans le désordre
| prevText=L'exécution dans le désordre
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
oetoa8aqxjlhvczrqo9kmy3hpyx66nr
774831
774830
2026-10-11T14:59:11Z
Mewtow
31375
774831
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
==La différence entre "lecture avant émission" et "lecture après émission"==
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
==L'implémentation d'un ''scheduler'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
La sélection de la micro-opération à émettre est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'exécution dans le désordre
| prevText=L'exécution dans le désordre
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
kycw9v13iftk1cm67m2r0wrk2fpv1b0
774832
774831
2026-10-11T14:59:22Z
Mewtow
31375
/* La différence entre "lecture avant émission" et "lecture après émission" */
774832
wikitext
text/x-wiki
Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Dans ce chapitre, nous allons étudier ce qu'il y a dans un ''szcheduler'', histoire de voir comment celui-ci peut détecter les dépendances entre instructions. Mais avant cela, parlons rapidement de son interface.
Un ''scheduler'' dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture.
Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus.
Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées.
[[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']]
Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout.
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder.
Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires.
{|
|[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]]
|[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]]
|}
==L'implémentation d'un ''scheduler'' : généralités==
Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment.
[[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']]
La sélection de la micro-opération à émettre est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances.
Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''.
[[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]]
La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques, avec une ALU entière séparées de la FPU, une unité mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul. Par exemple, on ne doit pas envoyer une µop entière dans une unité de calcul flottante.
De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir une ALU entière pour les opérations simples (additions, soustractions, opérations logiques), un multiplieur séparé pour les multiplications/divisions, un ''barrelshifter'' pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée.
[[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]]
Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge.
Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus.
: Il faut noter que le problème est aggravé avec la lecture après émission, vu que lire les registres prend un étage de pipeline entier.
===La logique d'éveil : l'émission anticipée===
La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement.
Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé.
'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, quelques cycles avant la fin de l'instruction. Mais ca n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Le signal d'éveil réveille alors toutes les multiplications dans le ''scheduler''.
Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. Elle est composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré.
L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée.
L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée.
===La logique de sélection : âge ou position ?===
La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur le numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''schedulers''.
Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''.
La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Alors certes, pas seulement un encodeur à priorité, mais c'est le circuit central. L'avantage de la sélection par position est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter.
La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. L'encodeur à priorité doit alors travailler en tenant compte de l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente.
Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position.
===La compaction des ''scheduler''===
À chaque cycle, les micro-opérations émises laissent un vide dans le ''scheduler''. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes.
Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple.
==Les ''schedulers'' basées sur une matrice de dépendances==
L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre.
===Rappels sur le registre de réservation/disponibilité===
L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise.
Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre).
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul.
===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits===
La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués.
L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission.
[[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]]
L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m.
[[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]]
Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail.
[[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]]
==Les ''schedulers'' basées sur une mémoire associative==
Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées.
Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement.
[[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]]
En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même.
[[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]]
L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple.
Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi.
===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées===
Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion.
Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits.
Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long.
L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''.
[[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]]
Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc.
Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout.
===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission===
D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible.
La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite.
Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' :
* une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ;
* une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ;
* une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée.
C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle.
===Les optimisations de la consommation d'énergie avancées===
D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles.
Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs.
Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales.
===Remplacer la mémoire associative par une RAM===
Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux.
La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs.
Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée.
[[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]]
==Les ''schedulers'' avec préplanification==
Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat.
[[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]]
===L'usage de FIFO multiples===
La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate.
A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre.
[[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]]
===La technique du tampon trié===
La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée.
[[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]]
La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts.
L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut.
[[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]]
Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification.
[[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]]
===Les ''scheduler'' avec allocation temporelle statique===
L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences.
L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance.
L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise.
Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire.
Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro.
Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles.
Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles.
Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée.
Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction !
La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'exécution dans le désordre
| prevText=L'exécution dans le désordre
| next=Le renommage de registres
| nextText=Le renommage de registres
}}
</noinclude>
51bm8f35ewrbfeww3ovzhmrhw3bernf
Mathc initiation/007y
0
84577
774884
2026-10-11T18:15:12Z
Xhungab
23827
news
774884
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/007r|f3.h ]] ...... '''f(x) = x avec x = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007s|c3a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007t|c3an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007u|c3bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007z|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007v|c3fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007w|c3fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/007x|c3fsap.c]]
{{AutoCat}}
6kr5ogql9l8lajmri4paz23a7ye2gy2
774885
774884
2026-10-11T18:17:41Z
Xhungab
23827
774885
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007s|c3a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007t|c3an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007u|c3bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook.png|thumb|]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007z|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007v|c3fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007w|c3fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/007x|c3fsap.c]]
{{AutoCat}}
3na98fyvl6fcxaxd77e0gxiafoklsfg
774907
774885
2026-10-11T18:57:39Z
Xhungab
23827
774907
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007s|c3a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007t|c3an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007u|c3bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [0.:pi] [0.:pi]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook 5.png|thumb]]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007z|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007v|c3fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007w|c3fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/007x|c3fsap.c]]
{{AutoCat}}
ftwazv6v3ftwp2144r4m90hfgkfjuy0
774908
774907
2026-10-11T18:59:35Z
Xhungab
23827
774908
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a594#Se familiariser avec les series de Fourier :| Sommaire]]
:
:
==La fonction : f(x) = x ...................... avec x = [-PI..PI]==
:
.
:
Copier la bibliothèque dans votre répertoire de travail :
* [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]]
* [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]]
* [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]]
:
.
:
==La fonction f :==
* [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [-PI..PI]'''
:
.
:
==Les coefficients de la séries de Fourier :==
* [[Mathc initiation/007s|c3a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* [[Mathc initiation/007t|c3an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* [[Mathc initiation/007u|c3bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
{| class="wikitable"
|+ Séries de Fourier
|-
! plot [0.:pi] [0.:pi] !! plot [-pi:4*pi] [-3.5:4.0]
|-
| [[File:Fourier serie for my wikibook.png|thumb|]] ||
[[File:Fourier serie for my wikibook 5.png|thumb]]]
|}
Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/007z|gnuplot]]''' '''les 9 premiers termes''' de la série.
* [[Mathc initiation/007v|c3fs.c]]
Avec ce fichier C nous allons pouvoir dessiner '''les N premiers termes''' de la série.
* [[Mathc initiation/007w|c3fsN.c]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/007x|c3fsap.c]]
{{AutoCat}}
337qsa3rj2w4dqwplrfdidqbrj51ww1
Mathc initiation/007s
0
84578
774887
2026-10-11T18:21:34Z
Xhungab
23827
news
774887
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3a0.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double a = -PI;
double b = +PI;
double a0 = 1./T * fourier_a0(f, a, b);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a0 = 1/T int( (%s) dx = %.6f\n"
" (a \n\n\n\n", feq, a0);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( (t) dx = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
quiqy8z4snrdkz3o5prug7s6pcrrgw9
Mathc initiation/007t
0
84579
774888
2026-10-11T18:22:58Z
Xhungab
23827
news
774888
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3an.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double n = 1.; /* You can change the value of n */
double an = 2./T * fourier_an(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_%.0f = 2/T int( (%s) cos(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, an);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_1 = 2/T int( (t) cos(1 omega t) dt = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
ic8xtu9w897ilpglwok6k9qifs8bmei
Mathc initiation/007u
0
84580
774889
2026-10-11T18:24:25Z
Xhungab
23827
news
774889
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3bn.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double n = 1.; /* You can change the value of n */
double bn = 2./T * fourier_bn(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" b_%.0f = 2/T int( (%s) sin(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n", n, feq, n, bn);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
b_1 = 2/T int( (t) sin(1 omega t) dt = 2.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
mreha43j7w16n1nqwtv4wyh86ncw2fy
Mathc initiation/007v
0
84581
774890
2026-10-11T18:26:17Z
Xhungab
23827
news
774890
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 0.000000
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
30zncxhqnd19683xr0qszbg0djmvyei
774891
774890
2026-10-11T18:27:35Z
Xhungab
23827
774891
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 0.000000
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
ez9nr76c92uuqc2zbrzzwza1h0xvbbe
Mathc initiation/007w
0
84582
774893
2026-10-11T18:29:25Z
Xhungab
23827
news
774893
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier| c3fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
qm8skoq533fmj4kxz1ahly4in9blvxp
774894
774893
2026-10-11T18:29:48Z
Xhungab
23827
774894
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier| c3fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
...
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
k0yvrg3ozbhx9j1txzl9fb3861p3ewk
Mathc initiation/007x
0
84583
774898
2026-10-11T18:31:46Z
Xhungab
23827
news
774898
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier| c3fsap.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fsap.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 10
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = +PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n Amplitude spectrumn Phase spectrum\n\n");
for( n=1; n<N; n++)
printf(" %3d A[%02d] = %+.3e Phase[%02d] = %+7.3e rad "
"= %+7.2f°\n",
n, n,sqrt(A[n]*A[n]+B[n]*B[n]), n,atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> t
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.000000000
n Amplitude spectrumn Phase spectrum
1 A[01] = +2.000e+00 Phase[01] = +1.571e+00 rad = +90.00°
2 A[02] = +1.000e+00 Phase[02] = -1.571e+00 rad = -90.00°
3 A[03] = +6.667e-01 Phase[03] = +1.571e+00 rad = +90.00°
4 A[04] = +5.000e-01 Phase[04] = -1.571e+00 rad = -90.00°
5 A[05] = +4.000e-01 Phase[05] = +1.571e+00 rad = +90.00°
6 A[06] = +3.333e-01 Phase[06] = -1.571e+00 rad = -90.00°
7 A[07] = +2.857e-01 Phase[07] = +1.571e+00 rad = +90.00°
8 A[08] = +2.500e-01 Phase[08] = -1.571e+00 rad = -90.00°
9 A[09] = +2.222e-01 Phase[09] = +1.571e+00 rad = +90.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
6k3qlg417govwzn6vft91gtkf59qin5
Mathc initiation/007z
0
84584
774900
2026-10-11T18:42:44Z
Xhungab
23827
news
774900
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
'''Fichier de commande gnuplot :'''
<syntaxhighlight lang="gnuplot">
# ---------------------
# Copy and past this file into the screen of gnuplot
#
#
plot [-pi:4*pi] [-3.5:4.0]\
x lt 9 lw 5,\
-pi lt 3 lw 3,\
pi lt 3 lw 3,\
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)\
lt 4 lw 3
reset
</syntaxhighlight>
{{AutoCat}}
d87zabz29nusjyquieo5h7f8j0d27px
774909
774900
2026-10-11T19:01:10Z
Xhungab
23827
774909
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
'''Fichier de commande gnuplot :'''
<syntaxhighlight lang="gnuplot">
# ---------------------
# Copy and past this file into the screen of gnuplot
#
#
plot [-pi:pi] [-3.5:4.0]\
x lt 9 lw 5,\
-pi lt 3 lw 3,\
pi lt 3 lw 3,\
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)\
lt 4 lw 3
reset
</syntaxhighlight>
'''Fichier de commande gnuplot :'''
<syntaxhighlight lang="gnuplot">
# ---------------------
# Copy and past this file into the screen of gnuplot
#
#
plot [-pi:4*pi] [-3.5:4.0]\
x lt 9 lw 5,\
-pi lt 3 lw 3,\
pi lt 3 lw 3,\
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)\
lt 4 lw 3
reset
</syntaxhighlight>
{{AutoCat}}
t86btwinbu1xeitufdtwgege4skh7i3
774911
774909
2026-10-11T19:02:27Z
Xhungab
23827
774911
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
'''Fichier de commande gnuplot :'''
<syntaxhighlight lang="gnuplot">
# ---------------------
# Copy and past this file into the screen of gnuplot
#
#
plot [-pi:4*pi] [-3.5:4.0]\
x lt 9 lw 5,\
-pi lt 3 lw 3,\
pi lt 3 lw 3,\
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)\
lt 4 lw 3
reset
</syntaxhighlight>
'''Fichier de commande gnuplot :'''
<syntaxhighlight lang="gnuplot">
# ---------------------
# Copy and past this file into the screen of gnuplot
#
#
plot [-pi:pi] [-3.5:4.0]\
x lt 9 lw 5,\
-pi lt 3 lw 3,\
pi lt 3 lw 3,\
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)\
lt 4 lw 3
reset
</syntaxhighlight>
{{AutoCat}}
5c3kym5lch9uxpukozo431fk5e6ehdr
Boîte à jeux/Dutch pablo
0
84585
774905
2026-10-11T18:54:05Z
Cso75
124351
Règles du Pablo ou du Dutch
774905
wikitext
text/x-wiki
{{Boîte à jeux|cartes}}
== Matériel ==
* 2 joueurs ou plus
* 1 jeu de 52 cartes
* 1 feuille
* 1 crayon
== But du jeu ==
Faire le moins de points possibles.
La partie cesse lorsqu'un joueur atteint ou dépasse 100 points (ou un nombre de points convenu en début de partie)
== Préparation ==
Au premier tour, un tirage au sort désigne le premier joueur. Ensuite, le perdant de la manche précédente commence.
On distribue à chacun des joueurs 8 cartes faces cachées.
On retourne la première carte du paquet restant, l'entame, afin de former la défausse.
== Déroulement d'une manche ==
Le joueur dont c'est le tour:
* Pioche une carte : il peut prendre la dernière carte de la défausse (face visible) ou la carte du dessus de la pioche (il ne la montre pas aux autres joueurs dans ce cas)
* Il peut alors :
** si la carte est un 7 et qu'il ne veut pas la garder, défausser le 7 et regarder (pour lui seul) une carte de son jeu
** si la carte est un 8 et qu'il ne veut pas la garder, défausser la 8 et regarder (pour lui seul) une carte du jeu d'un adversaire
** si la carte est un 9 et qu'il ne veut pas la garder, défausser le 9 et échanger sans les montrer une carte de son jeu avec une carte du jeu d'un adversaire
** la garder et intégrer la carte à son jeu, face cachée et défausser une ou plusieurs cartes: il peut défausser plusieurs cartes si elles ont la même valeur (par exemple le 4 de trefle et le 4 de coeur). Pour ce faire, il procède comme suit:
*** Il retourne une première carte, qu'il veut défausser. Mettons qu'il découvre un 5.
*** s'il pense savoir où se trouvent d'autres 5 dans son jeu il peut retourner ces cartes également.
*** s'il se trompe, il garde toutes le cartes et les remet face caché.
*** s'il n'a bien retourné que des cartes de même valeur, il les défausse toutes.
*** Note 1: En aucun cas la carte piochée et intégrée à son jeu ne peut être défaussée.
*** Note 2: S'il a pris la carte visible de la défausse, il doit l'intégrer à son jeu et rejeter une ou plusieurs cartes.
== Fin de la manche ==
La manche partie s'arrête s'il n'y a plus de pioche ou lorsque un joueur dit "PABLO" ou "DUTCH" à la fin de son tour.
Dans ce dernier cas, le la manche se termine à la fin du tour.
Dès qu'un joueur a dit "PABLO" ou "DUTCH", et jusqu'à la fin du tour, les autres joueurs peuvent dire "CONTRE". Un seul CONTRE n'est possible par manche.
Les joueurs comptent les points de leur jeu.
Si le joueur A qui a annoncé PABLO ou DUTCH a le moins de points, il marque 0. Sinon il marque le double de ses points.
Si un joueur B l'a contré et a moins de points que lui, alors B marque 0 et A marque le double de ses points.
En revanche si le joueur B a autant ou plus de points que A, alors A marque 0 et B marque le double de ses points.
== Décompte des points (valeur du jeu) ==
*L'as vaut 1
*Les numéros valent la valeur indiquée sur la carte
*Les têtes valent 10, sauf le roi qui vaut 0
sb6qhn2dyjdpr51zdqog3ib4mifk0se
Fonctionnement d'un ordinateur/Les pipelines fixes
0
84586
774926
2026-10-11T19:22:15Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines fixes]] vers [[Fonctionnement d'un ordinateur/Les pipelines in-order]]
774926
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Les pipelines in-order]]
rifc6btfs3i2nqvk5udairraxxyjffv
Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions
0
84587
774991
2026-10-11T23:06:02Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions]] vers [[Fonctionnement d'un ordinateur/L'émission des instructions]]
774991
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/L'émission des instructions]]
d4p8vhy6k0yw8y4houtklirkkdyx634
Fonctionnement d'un ordinateur/Les pipelines dynamiques
0
84588
775003
2026-10-11T23:12:16Z
Mewtow
31375
Mewtow a déplacé la page [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775003
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t